# Probity — the complete open specification, in one file > Probity runs a third-party AI agent — an MCP server, an A2A agent, or an agent > skill — inside a hardware-isolated micro-VM, watches what it actually does, and > signs a verdict anyone can re-check offline without Probity software. Scanners > read the code and infer intent. Probity executes the thing and signs the evidence. This file is the entire Probity Open Predicate Standard, inlined. Every document below is byte-identical to the one served at its own URL, and every one of those URLs is reachable with no account, no token and no login. Read `https://getprobity.dev/llms.txt` first if you want the short index: the verifier, the signed sample bundles, the JSON Schemas, the trust anchor, and how to describe Probity accurately. This file is the normative text those point at. The specification is what a verifier implements. It is deliberately separable from the runtime that produces the evidence: a reader who distrusts Probity entirely can still check a Probity verdict, because the check is Ed25519 over an RFC 8785 canonical pre-image and needs none of our code. Every document's canonical URL is given above it. A document also serves its raw markdown at the same URL with a `.md` suffix, and at `/raw`. ## Contents - https://getprobity.dev/predicate/v1/README - https://getprobity.dev/predicate/v1/signing-envelope - https://getprobity.dev/predicate/v1/adversarial-execution-evidence - https://getprobity.dev/predicate/v1/adversarial-execution-evidence-consumer-policy - https://getprobity.dev/predicate/v1/security-verdict - https://getprobity.dev/predicate/v1/static-scan-verdict - https://getprobity.dev/predicate/v1/scorecard-verdict - https://getprobity.dev/predicate/v2/a2a-mesh-scorecard - https://getprobity.dev/predicate/v2/mesh-quiescence - https://getprobity.dev/predicate/v1/catch-record - https://getprobity.dev/predicate/v1/vocabulary-registry - https://getprobity.dev/predicate/v1/evidence-scope-disclosure - https://getprobity.dev/predicate/v1/compliance-crosswalk - https://getprobity.dev/predicate/v1/ml-bom - https://getprobity.dev/predicate/v1/sut-provenance - https://getprobity.dev/predicate/v1/signed-evidence - https://getprobity.dev/predicate/v1/substrate-integrity - https://getprobity.dev/predicate/v1/kernel-substrate - https://getprobity.dev/predicate/v2/kernel-substrate - https://getprobity.dev/predicate/v1/launch-chain - https://getprobity.dev/predicate/v2/launch-chain - https://getprobity.dev/predicate/v1/open-predicate-standardization - https://getprobity.dev/predicate/v1/adversarial-execution-evidence-failure-codes - https://getprobity.dev/predicate/v1/quotation-fidelity ============================================================================== # DOCUMENT: v1/README # CANONICAL: https://getprobity.dev/predicate/v1/README # MARKDOWN: https://getprobity.dev/predicate/v1/README.md ============================================================================== # Probity Predicate Standard, v1 An open, vendor-neutral format for **signed, offline-verifiable security verdicts** about software artifacts and agent servers. A verdict says, in cryptographically checkable form, that a named artifact was run in isolation, attacked with a pinned corpus of exploits, and given a PASS / DEGRADED / FAIL outcome with per-class evidence. The format is the public surface; the runtime that produces it is out of scope for this standard. The base URI for a predicate type in this standard is: ``` https://getprobity.dev/predicate/v1/ ``` One exception: [adversarial-execution-evidence.md](adversarial-execution-evidence.md) is proposed for standardization in the **in-toto attestation namespace** and carries its own type URI (`https://in-toto.io/attestation/adversarial-execution-evidence/v0.7`) rather than a `getprobity.dev` one. A consumer always dispatches on the full `predicateType` string, so the namespace split is transparent to verification. ## Why this exists A verdict is only useful to a third party if that party can read and trust it **without** trusting or re-running the tool that produced it. CI systems, admission controllers, auditors, and registries need a stable, documented schema they can parse offline and verify against a configurable trust anchor. This standard publishes that schema so any tool can emit a conforming verdict and any tool can verify one. The intent is interoperability: a single agreed-upon shape for "this artifact was adversarially tested and here is the signed result," before the surface fragments into mutually incompatible vendor schemas. This document is the **scope and governance index**. Each component has its own self-contained specification (linked below). Read the envelope first; it is the substrate every predicate rides on. ## Wave 1 components (this release) Read in this order. The order is also the dependency order: later documents reference earlier ones. | # | Document | predicateType | What it carries | |---|---|---|---| | 1 | [signing-envelope.md](signing-envelope.md) | (envelope, predicate-agnostic) | DSSE PAEv1 + ed25519 envelope, key id, trust root, offline-verify procedure | | 2 | [vocabulary-registry.md](vocabulary-registry.md) | (registry, versioned) | The closed, versioned enums (`layer`, `violation_type`, `sink`, `auth_downgrade_cause`) and the governance process | | 3 | [catch-record.md](catch-record.md) | (component of the verdicts below) | The forensic catch-record: per-record host signature + batch Merkle root | | 4 | [security-verdict.md](security-verdict.md) | `.../security-verdict` | Per-server scan verdict for a single artifact against an attack corpus | | 5 | [adversarial-execution-evidence.md](adversarial-execution-evidence.md) | `https://in-toto.io/attestation/adversarial-execution-evidence/v0.7` | Bounded adversarial-stimulus evidence for a scanned artifact (Tier-2 runtime, in the box): fused recomputable result, digest-committed coverage bound, per-attack containment chain, signed intercept records | | 10 | [static-scan-verdict.md](static-scan-verdict.md) | `.../static-scan-verdict` | Tier-1 detection verdict: static scan of the declared surface — no microVM, no runtime execution; verdict-bearing but carries no catch-policy binding | | 9 | [sut-provenance.md](sut-provenance.md) | `.../sut-provenance` | Build provenance of a scanned SUT image: a known base image plus a fully integrity-pinned dependency closure with a deterministic binding hash | ## Substrate / mesh components (this release) These predicates attest properties **below** the application and network perimeter - the isolation substrate itself and the composition of a mesh of microVMs running over it. Their distinguishing property is that a perimeter-only sandbox cannot emit them: the evidence is captured at the hypervisor memory boundary and from the host's view of the guest, not at a syscall or network shim. Each carries its own self-contained specification under the same envelope, vocabulary, and versioning model. | # | Document | predicateType | What it carries | |---|---|---|---| | 6 | [ml-bom.md](ml-bom.md) | `.../ml-bom` | Execution-grounded model bill-of-materials: INTENT from a guest VMA walk, IDENTITY from a host-side 3-source resolver | | 7 | [mesh-quiescence.md](../v2/mesh-quiescence.md) | `https://getprobity.dev/predicate/v2/mesh-quiescence` | Freeze-atomicity attestation: bounded hypervisor-pause distributions, captured in-flight escape, closed-world egress | | 8 | [a2a-mesh-scorecard.md](../v2/a2a-mesh-scorecard.md) | `https://getprobity.dev/predicate/v2/a2a-mesh-scorecard` | Mesh composition-safety verdict over an aggregate MeshManifest subject (N member substrate roots + ModelBOM digests + hop graph) | These three are format-and-verification stable and ship signed conformance bundles that verify offline (see `conformance.json`). The shipped a2a-mesh-scorecard bundle binds a real sealed N=3 jailed mesh run (`honest_scope.substrate_roots_real == true`, `hop_graph_live == false`): real per-member byte-identity substrate roots with the canonical A->B->C delegation replayed, not observed live on the wire. Its composition-safety verdict is the offline `evaluate_mesh` evaluator's verbatim escalation finding (`scope == "ESCALATED"`, `causes == ["SCOPE_BROADENING"]`). Where a *capability* behind a predicate is still forward-looking - the egress-proxy ML-BOM identity source or a hardware host-trust root - the relevant document's status/maturity section says so plainly. ## Wave 2 (published, standardization track open) The first two carry the substrate evidence a wave-1 verdict references but does not contain; the third carries the bound around a published piece of evidence rather than any evidence itself. Their formats, producers, and offline verification are stable and their specifications are published here, so their type URIs resolve. What remains open is the *standardization* track: they have not been through the cross-vendor review the wave-1 components had, and a wave-1 verifier is not required to understand them. A consumer dispatches on the full `predicateType` string, so a verifier that does not implement them rejects them cleanly. | # | Document | predicateType | What it carries | |---|---|---|---| | 11 | [signed-evidence.md](signed-evidence.md) | `.../signed-evidence` | One signature over the guest-memory Merkle commitment, the value-binding to a signed session and verdict subject, and the cross-host fold pins | | 12 | [substrate-integrity.md](substrate-integrity.md) | `.../substrate-integrity` | The byteident proof families about the isolation substrate: fold-correctness, clone-uniqueness, and the VMGenID-reseed differential (`entropy-uniqueness` is retired and is a rejection target) | | 13 | [evidence-scope-disclosure.md](evidence-scope-disclosure.md) | `.../evidence-scope-disclosure` | The bound around a published piece of evidence: the layers it covers, its tier, whether it is a capture or a representative fixture, and the producer's prose statement of what it does not show | A wave-1 reference to a "substrate root" is an opaque digest plus a `substrate_proof_type` discriminator naming the proof family; the proof itself is served alongside and verified out of band against [substrate-integrity.md](substrate-integrity.md). ## Envelope summary Every predicate in this standard is the `predicate` field of an [in-toto Statement v1](https://github.com/in-toto/attestation/blob/main/spec/v1/statement.md) (`_type: https://in-toto.io/Statement/v1`), wrapped in a [DSSE](https://github.com/secure-systems-lab/dsse) envelope and signed with ed25519. The Statement binds the predicate to a **subject** (one or more named digests - always the artifact the verdict is about). The signature is computed over the DSSE Pre-Authentication Encoding (PAEv1) of the Statement, never over bare bytes, so a signature for one payload type can never be replayed as another. Each envelope MUST carry a `keyid` equal to the lowercase-hex SHA-256 of the RFC 7638 JWK thumbprint input for the ed25519 public key. The full construction, the trust-root model, and a worked offline-verify example are in [signing-envelope.md](signing-envelope.md). ## Versioning and forward-compatibility The version is part of the contract and is encoded in the predicate type URI: - **Major version in the URI.** Every predicate type carries its major version in the path (`/predicate/v1/...`). A consumer dispatches on the full `predicateType` string and MUST reject a predicate whose type it does not recognize. A new major version is a new URI. - **Additive-optional fields are a minor change and stay within the major version.** A producer MAY add new optional fields. Adding an optional field does NOT change the major version. - **A verifier MUST ignore unknown fields.** This is the load-bearing forward-compatibility rule: a v1 verifier presented with a predicate that carries optional fields it does not know MUST NOT reject the predicate on that basis alone. It validates the fields it understands and ignores the rest. This lets producers ship additive fields without breaking deployed verifiers. - **Rename, removal, or any semantic change of an existing field is a major version (v2).** Changing what a field means, deleting it, or renaming it is breaking and requires a new major version URI. It is never shipped as a silent in-place change a deployed verifier is expected to tolerate. ### Producer-strict vs verifier-tolerant (stated explicitly) This standard deliberately holds two rules that look contradictory and are not. They apply to different parties: - **A PRODUCER MUST be strict.** A conforming producer emits only the fields defined for the predicate version it claims, and rejects its own output if it carries fields it does not recognize. Producer-side validation is closed: unknown field means a bug, refuse to sign. - **A VERIFIER MUST be tolerant of additive fields.** A conforming verifier ignores fields it does not recognize rather than rejecting the whole predicate, exactly so that a producer shipping a newer minor version can be read by an older verifier. Strict-out, tolerant-in. The asymmetry is the mechanism that makes additive evolution safe. Note the one exception: **vocabulary values are fail-closed at a version** (see below) - a verifier MUST reject an unknown *enum value* even though it ignores an unknown *field*. The difference is that an unknown field is forward-compatible additive data, whereas an unknown enum value for a known field is a value the verifier cannot interpret and so cannot trust. ## Vocabulary registry and governance Some fields draw from closed enumerations (the `layer`, `violation_type`, and `sink` vocabularies used by [catch-record.md](catch-record.md)). These are not free text. They are published in a **versioned registry** ([vocabulary-registry.md](vocabulary-registry.md)) whose version is tied to the predicate version. - The registry is **fail-closed at a version**: a verifier MUST reject any value not present in the registry version it knows. There is no warn-and-continue mode. - **Adding a value is a registry revision**, published as a new registry list, not a silent change a deployed verifier is expected to tolerate. Producers and verifiers move to a new registry version together. - **Governance.** Probity maintains the registry initially. Additions and changes go through a published contribution process (a `CONTRIBUTING` document and pull-request review). The intent is to graduate maintenance to a neutral, multi-party process as adoption grows; until then, changes are proposed in the open and reviewed before a new registry version is cut. ## Related standards This standard is built to compose with the existing supply-chain and security-finding ecosystem rather than replace it. Where it aligns and where it diverges: - **[in-toto attestation](https://github.com/in-toto/attestation)** - ALIGNED. The envelope is an in-toto Statement v1; the subject-binding and `predicateType` routing are used exactly as in-toto defines them. - **[DSSE](https://github.com/secure-systems-lab/dsse)** - ALIGNED. The signing envelope is DSSE with PAEv1. No deviation. - **[SLSA Verification Summary Attestation (VSA)](https://slsa.dev/spec/v1.0/verification_summary)** - PARTIALLY ALIGNED. A Probity verdict plays a role similar to a VSA (a signed statement that a verification was performed and what it concluded). The engine-identity field in [security-verdict.md](security-verdict.md) is modeled on the VSA `builder.id` idea (an auditor-pinnable identity for the thing that produced the verdict). Probity diverges in carrying adversarial per-attack results and a forensic evidence reference rather than a policy-evaluation summary. - **[SARIF](https://sarifweb.azurewebsites.net/)** - CROSSWALK. Per-finding `check_id` values in layer-carrying verdicts (see [static-scan-verdict.md](static-scan-verdict.md)) map onto SARIF rule identifiers; a producer MAY carry findings as, or reference, a SARIF document. Probity does not re-specify SARIF; it points at it. (Layer findings are out of the adversarial-execution-evidence core; a producer that carries them does so via the `_ext` extension convention - see [adversarial-execution-evidence.md](adversarial-execution-evidence.md).) - **[OpenVEX](https://github.com/openvex/spec)** - ALIGNED for revocation. A verdict's trust in its attested substrate can be revoked out-of-band via an OpenVEX statement; the verifier consults a VEX log to void a stale PASS (see [security-verdict.md](security-verdict.md)). - **[CycloneDX](https://cyclonedx.org/)** - RELATED, not overlapping. CycloneDX describes what an artifact *contains* (a bill of materials). This standard describes what *happened* when the artifact was attacked. The two are complementary: a verdict's subject digest is the same artifact a CycloneDX BOM would describe. ## Verifiable today vs roadmap (honesty section) This standard is technically honest about what a third party can independently check versus what it must take as a producer self-assertion. Where a field is a self-assertion a verifier cannot verify on its own, the relevant document says so plainly. In summary: **Compliance-framework mapping.** The crosswalk from one signed verdict to the controls of ten compliance frameworks — and which mappings are emitted-today vs roadmap (the OSCAL assessment-results document, the per-finding OWASP/ATLAS taxonomy tags, and the per-framework evidence packs are roadmap; we emit DSSE/in-toto + SARIF today) — is in [compliance-crosswalk.md](compliance-crosswalk.md). Consumers MUST follow its bright line: a verdict's signature re-verifies offline; the framework mapping is an assertion of that crosswalk, never a claim that a bundle *contains* an OSCAL package or a taxonomy tag. **Independently verifiable today (no trust in the producer required):** - The DSSE signature and the `keyid` binding (anyone with the public key can verify the bytes). - The subject digest binding (the verdict is cryptographically tied to a specific artifact digest). - The catch-record per-record signatures and the batch Merkle root (the host's intercept evidence is signed and the set/order of records is committed). - Vocabulary conformance (a verifier checks every enum value against the registry version it knows). - The value-binding anchors in the security verdict **when the auditor supplies the external reference values** (corpus exhaustion against the auditor's known corpus id set; substrate freshness against the auditor's approved digest). These are verifiable, but only relative to anchors the auditor brings, not from the verdict alone. **Producer self-assertions (a verifier cannot independently confirm these from the verdict alone):** - That the attack corpus contained *real* exploits and not no-ops - a verifier confirms the corpus *digest* but cannot, from the verdict alone, confirm the corpus *content* is adversarial. Exhaustion is checked against an auditor-supplied anchor, not asserted as a solo completeness guarantee. - That the engine identity recorded is the engine that actually ran - pinnable and comparable against an auditor-known identity, but the verdict does not let a third party re-derive it. - Everything an [evidence-scope-disclosure](evidence-scope-disclosure.md) carries about its own scope - the `honest_scope` prose, the `is_synthetic_fixture` flag, the coverage map, and the live-attestation posture. That predicate is built entirely out of self-assertions on purpose, and its observation table names each one. A verifier checks the shape and the internal consistency rules; nothing in it grounds a claim that the work described happened. - For the substrate/mesh predicates: that the freeze timing numbers, the captured-segment evidence, the network-topology booleans, the hop log, and the VMA-walk access proofs were produced by a real run rather than asserted. A verifier confirms integrity, subject binding, and internal-rule consistency (well-formed distributions, the atomicity / bounded-pause rules, full-file-only ML-BOM identity, `auth_downgrade_cause` registration), and can chase every bound digest to its served evidence - but not external re-execution. Each substrate/mesh document states this in its own honesty section. **Roadmap (not in v1):** - A decentralized, keyless trust model (TUF / Sigstore-Fulcio) is deferred to a v2 envelope profile; v1 uses configurable public-key trust anchors. - Cross-vendor standardization review of the wave-2 predicate families above. Their formats and offline verification are stable and published; a wave-1 verifier is not required to implement them. - Automated, feed-driven substrate revocation (v1 uses a manually consulted VEX document). - The forward-looking *capabilities* behind the substrate/mesh predicates (the egress-proxy ML-BOM identity source, the hardware/zkVM host-trust root). The predicate *formats* and offline verification are stable now; the shipped a2a-mesh-scorecard bundle binds a real sealed N=3 jailed run (`substrate_roots_real == true`, `hop_graph_live == false`) carrying the offline mesh evaluator's verbatim escalation verdict. The forward-looking leg is the live on-the-wire delegation observation (`hop_graph_live == true`), not yet shipped. ## Conventions - The key words MUST, MUST NOT, REQUIRED, SHOULD, SHOULD NOT, MAY, and OPTIONAL in every document of this standard are to be interpreted as described in [RFC 2119](https://www.rfc-editor.org/rfc/rfc2119). - All digests are SHA-256 unless stated otherwise, encoded as 64-character lowercase hexadecimal. - All documents and all on-wire values are ASCII or UTF-8; no document of this standard requires any non-ASCII content. ============================================================================== # DOCUMENT: v1/signing-envelope # CANONICAL: https://getprobity.dev/predicate/v1/signing-envelope # MARKDOWN: https://getprobity.dev/predicate/v1/signing-envelope.md ============================================================================== # Probity Signing Envelope, v1 This document specifies the signing envelope shared by every predicate in the Probity Predicate Standard. It is **predicate-agnostic**: the same envelope, the same signature construction, and the same verification procedure apply to every predicate type. A verifier that implements this document can check any conforming Probity verdict offline, given a trust anchor, regardless of which predicate the verdict carries. 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 A verdict must be readable and trustworthy by a third party who does not run, and does not trust, the tool that produced it. That requires (a) a documented, stable byte layout for the signed payload, (b) a signature scheme any standard implementation can verify, and (c) a trust model that lets the verifier decide *whose* signatures to accept. This document provides all three. It deliberately separates the signed payload from any transport or distribution wrapper so that a verifier always knows exactly which bytes the signature commits to. ## Envelope structure Every predicate is the `predicate` field of an [in-toto Statement v1](https://github.com/in-toto/attestation/blob/main/spec/v1/statement.md): ```json { "_type": "https://in-toto.io/Statement/v1", "subject": [ { "name": "", "digest": { "sha256": "<64-hex>" } } ], "predicateType": "https://getprobity.dev/predicate/v1/", "predicate": { "...": "predicate-specific fields" } } ``` - **`_type`** (REQUIRED) - MUST be exactly `https://in-toto.io/Statement/v1`. - **`subject`** (REQUIRED) - a non-empty array of subject objects, each with a `name` and a `digest` map. The subject is always the artifact the verdict is *about* (the per-predicate document states exactly which digest). A verifier binds the verdict to the artifact through this digest. - **`predicateType`** (REQUIRED) - the full predicate-type URI. A verifier dispatches on this string and MUST reject a type it does not recognize. - **`predicate`** (REQUIRED) - the predicate body, specified by the per-predicate document. The Statement is then wrapped in a DSSE envelope and signed. ### DSSE envelope The signed artifact is a [DSSE](https://github.com/secure-systems-lab/dsse) envelope: ```json { "payloadType": "application/vnd.in-toto+json", "payload": "", "signatures": [ { "keyid": "<64-hex>", "sig": "" } ] } ``` - **`payloadType`** (REQUIRED) - `application/vnd.in-toto+json` for an in-toto Statement payload. - **`payload`** (REQUIRED) - base64 of the serialized in-toto Statement (the bytes the signature commits to; see Canonicalization). - **`signatures`** (REQUIRED) - a non-empty array. Each entry carries a `keyid` and a `sig`. - **`keyid`** (REQUIRED, in every signature entry) - see Key identity below. This is MANDATORY in every envelope; an envelope without a `keyid` is non-conforming. - **`sig`** (REQUIRED) - base64 of the raw 64-byte ed25519 signature over the PAE of the payload. ## Signature algorithm The signature scheme is **ed25519** ([RFC 8032](https://www.rfc-editor.org/rfc/rfc8032)) over the DSSE Pre-Authentication Encoding (PAEv1) of `(payloadType, payload)`. The `sig` value is the raw 64-byte ed25519 signature, base64-encoded. ## Key identity (`keyid`) The `keyid` MANDATORY in every envelope is the [RFC 7638](https://www.rfc-editor.org/rfc/rfc7638) JWK thumbprint of the ed25519 public key, with the project's lowercase-hex encoding: ``` canonical_jwk = {"crv":"Ed25519","kty":"OKP","x":""} (the required OKP members crv, kty, x in lexicographic order, no whitespace, UTF-8) keyid = lowercase_hex( SHA-256( canonical_jwk bytes ) ) ``` The hash input is the **RFC 7638 canonical JWK** built from the raw 32-byte ed25519 public key (the RFC defines the thumbprint as the SHA-256 of this canonical JWK). We adopt the RFC 7638 *input* with the project-standard lowercase-hex *encoding* (RFC 7638 itself leaves the output encoding to the application), so the result is 64 lowercase hexadecimal characters. The `keyid` lets a verifier select the matching public key from its trust anchor before checking the signature, and lets two parties refer to the same key unambiguously without exchanging the key bytes. The reference oracle for this derivation and its cross-language conformance vectors is [`gen-keyid-vectors.py`](gen-keyid-vectors.py). > Note: the `keyid` is a hint, not a credential. It is excluded from every signed pre-image (the DSSE > PAE and the catch-record canonical bytes), so re-deriving it under a different scheme re-stamps > metadata without invalidating any signature. Hashing a public key to a compact, stable identifier > follows the cosign / Rekor transparency-log-ID convention (`GetTransparencyLogID` = > hex(SHA-256(DER SubjectPublicKeyInfo))); this envelope uses the RFC 7638 JWK-thumbprint input rather > than DER SubjectPublicKeyInfo, and is NOT the X.509 Subject Key Identifier of RFC 5280 (which is a > SHA-1 over the public-key BIT STRING). The envelope `keyid`, the catch-record per-record `key_id`, > and the freeze-binding `key_id` are now ALL derived by this single RFC 7638 scheme; earlier > revisions derived the catch-record `key_id` from the DER SubjectPublicKeyInfo, but that divergence > is retired. The per-record `key_id` field is defined in [catch-record.md](catch-record.md). ## PAE construction (PAEv1) The signature is never computed over bare bytes. It is computed over the DSSE Pre-Authentication Encoding, which prefixes the payload with its type and both lengths so a signature is bound to a specific payload type: ``` PAE(type, body) = "DSSEv1" SP LEN(type) SP type SP LEN(body) SP body ``` where: - `SP` is a single ASCII space (0x20); - `"DSSEv1"` is the literal six-byte ASCII string; - `LEN(x)` is the byte length of `x` rendered as an ASCII decimal integer with no leading zeros; - `type` and `body` are the raw bytes (not base64) of the payload type and payload, concatenated positionally with the single-space separators shown. For the **envelope** itself, `type = "application/vnd.in-toto+json"` and `body =` the serialized in-toto Statement bytes (the same bytes that are base64-encoded into `payload`). ### Component payload types The catch-record component carries its own signatures over its own canonical pre-images, under dedicated payload types so a catch-record signature can never be replayed as a Statement signature (or vice versa). These types are used as the `type` argument to the same PAE construction above: | What is signed | PAE `type` | PAE `body` | |---|---|---| | In-toto Statement (verdict envelope) | `application/vnd.in-toto+json` | serialized Statement bytes | | Individual catch-record | `application/vnd.probity.catch-record.v2+json` | the catch-record canonical pre-image | | Catch-record batch root | `application/vnd.probity.catch-batch-root.v1` | `uint64-BE(count)` followed by the 32-byte root | A verifier MUST reconstruct the PAE with the payload type it expects for the artifact it is checking and MUST reject on any mismatch. The per-record and batch-root pre-image constructions are specified in [catch-record.md](catch-record.md). ## Canonicalization (and the residual fragility risk) The bytes the envelope signature commits to are the serialized in-toto Statement. Producers serialize the Statement deterministically (a JSON canonicalization: sorted object keys, no insignificant whitespace, UTF-8). The signed bytes are exactly those carried, base64-encoded, in `payload`. The load-bearing rule is **separate the signed payload from any transport wrapper.** A verifier MUST verify the signature over the *exact payload bytes* carried in the envelope (decode `payload` from base64 and use those bytes), and MUST NOT re-serialize, re-canonicalize, pretty-print, or otherwise transform the Statement before verifying. Any tool that ships, stores, or proxies a verdict MUST preserve the payload bytes byte-for-byte; wrapping a verdict in a transport envelope (an HTTP body, a message bus frame, an A2A message) MUST NOT mutate those bytes. > Residual risk (stated honestly): JSON canonicalization is fragile across a supply chain. If any > intermediary re-serializes the Statement - even a semantically identical re-encoding - the byte > stream changes and the signature fails. This is by design (the signature is over bytes, not over a > JSON value), but it means producers and intermediaries MUST treat the signed payload as an opaque > blob, not as a JSON document to be reformatted. Carry the base64 `payload` verbatim end to end. ## Trust root (v1) A signature proves the bytes were signed by *some* key; it does not, by itself, say that key is *trusted*. The verifier decides trust. - **A verifier MUST accept a configurable trust anchor.** The set of acceptable signing keys (by `keyid` and public-key bytes) MUST be supplied to the verifier as configuration. A verifier MUST NOT hardcode any single key as the only acceptable one. This is what makes the format genuinely open: any party can publish verdicts under their own key and any verifier can be pointed at that key. - **`probity.pub` is documented as the DEFAULT, never a format constraint.** A verifier MAY ship the Probity public key as a convenience default trust anchor, but the format does not require it and a conforming verdict signed by any key the verifier trusts is valid. Nothing in the wire format names or assumes `probity.pub`. - **Key distribution (non-normative).** A producer MAY publish its verification keys at a `.well-known` endpoint. The following is an EXAMPLE shape, NON-NORMATIVE - verifiers are not required to fetch it and producers are not required to publish it: ``` GET https:///.well-known/verification-keys ``` ```json { "keys": [ { "keyid": "<64-hex SHA-256 of the RFC 7638 JWK thumbprint input for the ed25519 public key>", "alg": "ed25519", "public_key_b64": "" } ] } ``` A verifier that chooses to use such an endpoint resolves the `keyid` from the envelope against the published keys, then verifies. The endpoint is a distribution convenience; trust still rests on the verifier's configured decision to accept that producer's keys. - **Keyless / decentralized trust is deferred to v2.** A keyless model where signing identity is bound to a workload identity through a transparency-log-backed certificate ([TUF](https://theupdateframework.io/), [Sigstore](https://www.sigstore.dev/) Fulcio) is explicitly out of scope for the v1 envelope and is reserved for a v2 envelope profile. v1 trust is configurable public keys, full stop. ## Verification procedure (predicate-agnostic, offline) Given an envelope and a configured trust anchor, a verifier MUST: 1. Parse the DSSE envelope. Confirm `payloadType == application/vnd.in-toto+json` and that `signatures` is non-empty and each entry carries a `keyid`. 2. Decode `payload` from base64 to the exact Statement bytes. Do not re-serialize. 3. For each signature entry, resolve `keyid` against the trust anchor to a public key. If no configured key matches, the verifier MUST reject (untrusted signer). Independently confirm `keyid == lowercase_hex(SHA-256(RFC 7638 canonical JWK of the public key))` for the resolved key. 4. Reconstruct `PAE("application/vnd.in-toto+json", )` and verify the ed25519 `sig` against it with the resolved public key. Reject on failure. 5. Parse the Statement. Confirm `_type == https://in-toto.io/Statement/v1`, `subject` is non-empty, and `predicateType` is a type the verifier recognizes. Reject an unrecognized `predicateType`. 6. Hand the `predicate` and `subject` to the predicate-specific verification procedure (in the per-predicate document), which performs subject binding, vocabulary checks, any referenced catch-record verification, and any auditor-supplied value-binding checks. No live execution environment is required at any step. Verification is pure cryptography plus JSON parsing plus the verifier's configured anchors. ## Worked offline-verify example The following illustrates a verification of a verdict envelope. Values are abbreviated; the structure is exact. Input envelope (`verdict.dsse.json`): ```json { "payloadType": "application/vnd.in-toto+json", "payload": "eyJfdHlwZSI6Imh0dHBzOi8vaW4tdG90by5pby9TdGF0ZW1lbnQvdjEiLCAiLi4uIjogIi4uLiJ9", "signatures": [ { "keyid": "3f2a...c91d", "sig": "MEUCIQ...base64-ed25519-sig...==" } ] } ``` Trust anchor (configured): a key whose raw 32-byte public key hashes to `3f2a...c91d`. Steps: 1. `payloadType` is `application/vnd.in-toto+json`. OK. 2. `payload_bytes = base64_decode(envelope.payload)`. Keep verbatim. 3. The signature's `keyid` is `3f2a...c91d`. The configured anchor has a key whose RFC 7638 JWK thumbprint (hex) is `3f2a...c91d`. Resolve it. (If it were not in the anchor: reject.) 4. `pae = b"DSSEv1" + b" " + b"28" + b" " + b"application/vnd.in-toto+json" + b" " +` `str(len(payload_bytes)).encode() + b" " + payload_bytes`. Note `28` is the byte length of `application/vnd.in-toto+json`. Verify `sig` over `pae` with the resolved ed25519 key. Suppose it verifies. OK. 5. Parse `payload_bytes` as JSON. `_type` is the Statement v1 URI, `subject` is non-empty, `predicateType` is `https://getprobity.dev/predicate/v1/security-verdict` (recognized). OK. 6. Dispatch to the security-verdict procedure with the parsed `predicate` and `subject`. That procedure resolves the catch-record batch root, checks the subject digest against the expected artifact, and applies any auditor-supplied exhaustion / freshness anchors. If every step passes, the verdict is authentic, bound to its subject artifact, and produced by a trusted key - established without running anything the producer ran. ## Related standards - **DSSE** - this envelope IS DSSE with PAEv1 and ed25519; no deviation. - **in-toto attestation** - the payload is an in-toto Statement v1; subject binding and `predicateType` routing are used as in-toto defines them. - **Sigstore / cosign** - a verdict can be verified by any DSSE-aware tool given the payload type and the trust anchor; `keyless` Sigstore identity is the deferred v2 profile, not v1. - **TUF** - named as a v2 candidate for decentralized key distribution; not used in v1. ## Versioning This is the v1 envelope. Versioning has **two independent axes** that MUST NOT be conflated: - **The envelope axis.** A change to the signature algorithm, the PAE construction, the `keyid` derivation, or the trust model (for example, adopting a keyless profile) is an **envelope-version** concern. The migration of the `keyid` derivation onto the RFC 7638 JWK thumbprint (this revision) is exactly such a change: because the `keyid` is excluded from every signed pre-image, it re-stamps metadata without invalidating any signature, and it does NOT change any predicate body or schema. - **The predicate-schema axis.** The `predicateType` namespace (`/predicate/v1/`) bumps only when a predicate's own SCHEMA changes incompatibly. A pure envelope-axis change (such as the `keyid` re-derivation) does **NOT** bump the predicate-type namespace; predicates keep `/predicate/v1/...`. Additive optional fields in a predicate body affect neither axis. A v1 verifier MUST reject an envelope that claims a payload type or signature scheme it does not implement. ============================================================================== # DOCUMENT: v1/adversarial-execution-evidence # CANONICAL: https://getprobity.dev/predicate/v1/adversarial-execution-evidence # MARKDOWN: https://getprobity.dev/predicate/v1/adversarial-execution-evidence.md ============================================================================== # Adversarial Execution Evidence, v0.7 > ## THIS DOCUMENT IS NOT THE SPECIFICATION > > **The authoritative text is `spec/predicates/adversarial-execution-evidence.md` in the > `in-toto/attestation` fork**, mirrored byte-identically in the `aee-conformance` repository. That > document is 2,322 lines, whose sha256 is > `759d2383e5da36fa509dc335e6159a20b87641b25ebbadcf1676c55d75ffd8b0`; this one is 1,193. They are > deliberately different documents, and this one is a LOCAL RESTATEMENT maintained alongside the > authority — not a copy of it. > > **Do not cite this file as normative. Do not resolve a disagreement in its favour.** Where this text > and the authoritative text differ, the authoritative text wins and this file is the thing to fix. > > **Why this banner exists.** On 2026-07-30 an audit found this file carries 52 sentences imposing > `MUST` and 37 imposing `REQUIRED`, sits at a path named `spec/v1/`, nowhere stated that it was not > the specification, and had **no gate of any kind checking it against the authority** — verified by > searching `scripts/`, `.github/workflows/` and `.githooks/`, which returned zero references to it. > A reader had no way to tell it apart from the real thing, and "kept in lockstep by hand" is a > promise with nothing enforcing it. The counts are over sentences, taken with this banner and the > fenced examples excluded and wrapped paragraphs rejoined first; a sentence carrying both keywords > is counted under both. > > **What checks it now.** `scripts/lint_spec_restatement.py`, against the pin ledger > [`adversarial-execution-evidence.pins.json`](adversarial-execution-evidence.pins.json). The ledger > records the authority by content digest, never by version string or line number, so any edit to the > authority makes the pin stale and the gate red until a person re-reads both texts and re-pins. > Every normative sentence below is enumerated there: 8 are anchored to a span of the authority that > carries that sentence verbatim, and 80 are recorded as unanchored, meaning the authority states > the obligation in different prose and no mechanical check establishes that the two say the same > thing. > > **The pin does not close the semantic gap, and that is the honest limit of it.** For an unanchored > sentence this gate proves two things and no more: that the authority has not moved since a person > last read it against this text, and that every field name the sentence constrains exists in the > authority at all. It does not prove the obligation is one the authority imposes. Read the authority > before relying on an unanchored sentence, and read the ledger for which are which. **predicateType:** `https://in-toto.io/attestation/adversarial-execution-evidence/v0.7` > **v0.6 makes `basis: substrate` rows substrate-backed at TWO appraisal gates.** GATE 1 > (**coverage validity**, RATS RFC 9334 "Appraisal Policy for Evidence") is a key-independent > consumption precondition computed from the carried bytes: references resolve and class-match, > every covering observation payload is canonical `+json` carrying the reserved members with > `aeeRunBinding` equal to the derived run binding, `method` is capped by the weakest signed > `aeeMethod`, and `batchRoot` recomputes; a violation makes the attestation INVALID. GATE 2 > (the per-row **evidence tier** `declared`/`unattested`/`attested`, RATS "Appraisal Policy for > Attestation Results") is the one trust-relative step: the covering signatures verify against a > consumer-named substrate observation key. The producer's observation vocabulary now travels > ON-WIRE (`observationVocabulary`: labels, caught subset, JCS digest), so the `result` recompute > and GATE 1 are pure functions of carried bytes and archived attestations stay verifiable without > the producer's documentation. The reduction for `result` and the two gates are pinned by the > normative [`recompute-table.json`](recompute-table.json), which every conforming verifier > re-encodes. See the Changelog for the v0.3 -> v0.4 -> v0.5 -> v0.6 breaking-change list. Adversarial Execution Evidence is a signed, offline-verifiable record of a **bounded adversarial stimulus** run against a system under test (SUT) inside an attested containment substrate - most commonly an MCP server scanned by one `mlctl gate ` run. It carries, in one standalone in-toto predicate: - a **fused, recomputable `result`** (`pass` / `pass_indirect` / `degraded` / `fail`) - a deterministic, severity-independent reduction of the carried predicate that any verifier re-derives offline from the carried rows, the on-wire observation vocabulary, and the coverage maps alone; - the **observation environment** - digest-pinned references to the substrate, the attack corpus (with its embedded, digest-committed class manifest), the catch policy, the enforced network posture, the producer's on-wire `observationVocabulary`, and (whenever a row is `basis: substrate`) the `runEntropy` run-start beacon the run binding folds in; - the **coverage bound** - which assessment classes were assessed and an explicit disclosure map for every class that was not; - the **per-attack containment rows** - `attackResults[]`, each carrying the observed `containmentObserved` label plus the row's own `basis` (vantage), `method` (directness), `actualLayer`, and `observationRefs` binding it to the observation records that cover it; - the **signed observation evidence** - `observationRecords[]`, each an independently signed record of one interception, the armed vantage the run was observed under, or the seal that the vantage stayed armed to run-end, carried as a **standard DSSE envelope** `{ payload, payloadType, signatures[] }` whose `payload` is the base64 (RFC 4648 §4 standard alphabet) of the EXACT canonical `+json` bytes the substrate signed. A verifier verifies the per-record signature over `PAE(payloadType, payload)` FIRST, then reads the reserved payload members after. This document specifies the predicate body. The signing envelope, subject binding, and offline-verify mechanics are in [signing-envelope.md](signing-envelope.md). ## Purpose This predicate is the **active-stimulus sibling** to passive runtime monitoring attestations (such as the in-toto `runtime-trace` family): where a passive trace records an unbounded, monitor-specific log of whatever happened, this predicate records a *bounded* adversarial campaign - a pinned corpus of attacks dispatched against the SUT - with per-attack rows, a cryptographically committed coverage bound, and each observation independently signed. The design goal is that a consumer can recompute the outcome from the attestation alone, with no call back to the producer's infrastructure and no dependency on a document that does not travel with the statement. The outcome is a deterministic function of the carried evidence; the coverage denominator is committed by digest, so the producer cannot assert it unilaterally; the observation vocabulary travels on-wire, so the reduction is not something the producer's docs must supply; and each observation record verifies on its own before it is read. A producer cannot claim more than the evidence supports, and a producer claiming less is detectable, since dropping an inconvenient interception changes the committed batch root. Unlike Simple Verification Results (SVRs), which communicate opaque, delegated policy decisions, this predicate's `result` is a strictly deterministic, severity-independent reduction. No policy threshold, severity ruleset, or producer-private logic participates in the reduction; a consumer that wants to apply its own policy emits its own downstream verification summary over this evidence - this predicate does not carry one. Verdicts (pass/fail against an organization's policy) are deliberately out of scope; they belong in a downstream summary predicate computed over this evidence. 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). The on-wire JSON shape is pinned by a draft 2020-12 JSON Schema, the SSOT at `spec/schemas/v1/adversarial-execution-evidence.schema.json`, a **conformant structural profile** of the open standard: it gates the field set, primitive types, and the known member set of the structural objects (rows, records, environment carry `additionalProperties: false`), while the value vocabularies whose violation is *valid-but-fail-closed at the recompute gate* (`containmentObserved`, `basis`, `method`) are left OPEN and validated by the verifier, not the schema - so an out-of-vocabulary or (for `basis`/`method`) absent value is structurally valid and fail-closes at recompute. `actualLayer` and `networkPosture.posture` remain closed producer registries, and the predicate object itself is open so a reserved-prefix `aee*` or `evidenceTier` member is ignored rather than rejected. The **soundness** clauses below - the JCS corpus-manifest and observation-vocabulary digest equalities, the attack-level coverage exhaustion, the result recompute, the two consumption gates (coverage validity + evidence tier), the per-record DSSE proof-of-observation, the run binding, the RFC-6962 batch root, and the actualLayer/none coherence - are enforced by the producer's `validate()` gates AND the offline verifier rules, NOT by the schema alone; a schema-valid object is not necessarily a sound artifact. **I-JSON safe-integer profile (normative).** Every signed surface - the statement payload and every observation-record payload - is RFC 8785 (JCS) restricted to the RFC 7493 (I-JSON) profile: strings, integers of magnitude below 2^53, booleans, and null. A duplicate member, a non-integer numeric (a fractional `1.5`, an e-notation `1e+21`, a non-finite `Infinity`/`NaN`), or an integer at or above 2^53 MUST be rejected by every rail, so producer and verifier in any language derive identical bytes. **Statement-wide strict I-JSON (normative).** The whole statement is parsed as strict I-JSON: a duplicate member anywhere in the statement, at any depth and not only inside a covering record payload, makes the statement malformed. A lenient parser that silently keeps the last of a repeated member would let two rails disagree on identical bytes, so a verifier MUST reject a duplicate member statement-wide, fail-closed. **String well-formedness (normative).** Strict I-JSON also constrains the bytes of every string. A verifier MUST reject, statement-wide and fail-closed, any statement in which a string literal is not a well-formed sequence of Unicode scalar values: the statement MUST be valid UTF-8 with no overlong form and no surrogate encoded directly in UTF-8 (CESU-8); a `\u` escape naming a high surrogate MUST be immediately followed by a `\u` escape naming a low surrogate, and an unpaired surrogate escape of either half is malformed; a string MUST NOT contain a raw unescaped character below U+0020; and a `\u` escape MUST consist of exactly four hexadecimal digits, with no sign, whitespace, or radix prefix, so that a reader built on a permissive integer parser does not accept `\u+041` where a strict one rejects it. A lenient decoder does not fail on ill-formed bytes, it substitutes U+FFFD, and every check downstream of the decode then reads a string the producer never wrote; where a digest is recomputed from decoded strings rather than compared against carried bytes - which is how the `observationVocabulary` digest is defined - a producer could otherwise emit ill-formed bytes, derive the digest over the substituted form, and obtain a statement one conforming verifier calls valid and another calls malformed. A verifier MUST therefore apply this check to the raw bytes, before any decoded string is read. The profile also excludes the Unicode noncharacters - the code points U+FDD0 through U+FDEF, and U+nFFFE and U+nFFFF in every plane - which RFC 7493 section 2.1 forbids in the same sentence as surrogates. A noncharacter is a valid scalar value that nothing substitutes for, so unlike an ill-formed sequence it is not a cross-rail decoding split; it is excluded so that a verifier implementing the RFC 7493 label does not reject a record another verifier accepts, and it is rejected wherever a string literal appears, at any depth and in both member-name and value position. This rule governs whether the bytes denote admissible scalar values; the BMP-only rule below governs which of the remaining scalar values may appear. **Nesting-depth bound (normative).** A verifier MUST reject, fail-closed, a statement whose JSON nesting depth exceeds 128. Nesting depth is the number of arrays and objects open at a given point, counting the outermost `{` of the statement as depth 1; scalar values do not increase it. The bound is normative because it is not a resource limit alone: with no bound stated, implementations pick their own, and two conforming verifiers then disagree about whether identical bytes are evidence at all over the entire range between their choices. The counting rule is stated because implementations that increment per parsed value rather than per open container arrive one level apart from an identical constant. Record payloads are parsed under the same bound. **BMP-only string profile (normative).** On every signed surface, object member names and the `observationVocabulary.labels`/`caught` strings MUST be BMP-only - no supplementary-plane code point, no surrogate pair. RFC 8785 sorts by UTF-16 code unit, but a verifier that sorts by Unicode code point orders a name outside the BMP differently (a surrogate-led name sorts first under UTF-16 and last under code point), so an adversarial producer could mint a payload canonical under one reading and not the other, splitting `covers` versus `covers-nothing` - attestation-valid versus attestation-invalid on identical bytes. Restricting the sorted strings to the BMP makes UTF-16 and code-point order coincide, so the divergence is unconstructible; this is the string half of the safe-integer profile above. Probity's ASCII vocabulary trivially satisfies it. BMP-only is a verifier rejection obligation, not producer hygiene: a verifier MUST treat a supplementary-plane member name as making the covering payload cover nothing, and a supplementary-plane vocabulary entry as making the statement malformed, the same handling as non-canonical bytes. The UTF-16 sort rule (above) remains the defense in depth: a verifier orders `labels`/`caught` by UTF-16 code unit, so even against a rail that skipped this rejection rule, a non-conforming producer's bundle could not split verifier consensus. **Determinacy is established by vectors (normative honesty).** These bounds close the divergences the text can foresee: a stated depth, a fixed sort order, a pinned encoding. They do not close the ones it cannot. Where the text underdetermines a reading and no conformance vector exercises it, two implementations agreeing on that reading is evidence the text is determinate, not proof of it - the reading is untested rather than confirmed, and a third implementation could differ there in silence. Conformance is established by vectors; an agreement no vector has exercised is a candidate for the next vector, not a settled rule. All predicate field names are lowerCamelCase. ## Subject The subject is the **scanned SUT image, by digest** - the thing the evidence is about. It is NOT the digest of the attack corpus; that is carried in `observationEnvironment.corpus.digest`. Binding the subject to the scanned image lets an admission controller link the evidence to the exact image it is admitting. For this predicate `subject` MUST contain exactly one entry on a statement of any basis; a statement carrying zero or more than one subject is malformed, regardless of whether any row is `basis: substrate`. Its digest MUST carry a `sha256` value that is already lowercase 64-hex (the run binding takes it verbatim). ```json "subject": [ { "name": "", "digest": { "sha256": "<64-hex-digest>" } } ] ``` ## Run binding For any statement carrying at least one `basis: substrate` row, the **run binding digest** is the lowercase 64-hex SHA-256 of the RFC 8785 canonicalization of the object ```json { "aeeBindingVersion": "2", "catchPolicy": "", "corpus": "", "networkPosture": "", "observationVocabulary": "", "runEntropy": "", "subject": "", "substrate": "" } ``` Every input is run **configuration**, fixed before corpus injection. That is the admission test for any future input, not a coincidence: the arming record carries this digest inside its own signature and is signed before injection, so a value the producer could not know then would make the arming record unsignable, and no outcome of the run can ever appear here. `runEntropy` is a run-start value the substrate emits and commits inside the arming record's signature, so two executions sharing every other input still derive distinct bindings. Values are taken as written (no case-folding, no null fill), and `catchPolicy`, `corpus`, `runEntropy`, `substrate` and `subject[0]` MUST each carry a lowercase-64-hex `sha256`. So MUST `networkPosture.digest.sha256`, even though version 2 no longer reads it verbatim, because it is still compared byte for byte against a record's `aeePostureDigest`. The `observationVocabulary` digest is the one binding input with no canonicality rule of its own: the vocabulary digest-integrity check recomputes it from the carried arrays, so a non-canonical value cannot equal that recompute, and restating the requirement would add a condition that could never be the one to fail. A statement whose rows are all `basis: artifact` derives no binding and need not carry `runEntropy`. A verifier derives the digest **from the statement alone**; no field carries it. Every substrate-signed observation record commits to the run by carrying this digest as its `aeeRunBinding` member. The binding is **anti-splice**, not a freshness challenge: a record signed under a different subject, corpus, catch policy, network posture, observation vocabulary, substrate, or run-start entropy value cannot be spliced in. It carries no verifier nonce, and identical-configuration re-runs are distinguished only by the substrate-emitted `runEntropy` value, so a consumer that must exclude replay of a genuine record into a later identical-configuration run does so by rejecting reuse of a `runEntropy` value it has already seen. `aeeBindingVersion` names this construction; exactly ONE is implemented at a time. A future version that changes the construction (another hash algorithm, additional inputs, multiple subjects) names a new binding version, and a verifier MUST reject, fail-closed, a binding version it does not implement rather than attempt more than one construction. An arming record's payload MAY carry an explicit `aeeBindingVersion` member declaring its construction; a verifier reads it before deriving the run binding and rejects it fail-closed (the arming record covers nothing) when the value is a version it does not implement, distinguishably from a run-binding digest mismatch. An absent member defaults to the **implemented** version rather than to a fixed number, which is what keeps the declaration optional across a version change; the carried value never drives the derivation (a verifier always derives under the version it implements, so a record declaring the implemented version but constructed otherwise still fails on the digest), which keeps the digest authoritative. ### What version 2 changed, and why neither change costs a byte Both inputs are material the statement already carries, so the wire is unchanged and no new comparison exists: each closes through the equality every record's `aeeRunBinding` is already put to. Version 1's `networkPosture` input was the value of that member's own `digest.sha256`, which left the `posture` string beside it outside every signature. The posture configuration this predicate digests travels nowhere in the statement, so no verifier can check the string against the digest, and a party holding only the envelope key could swap one posture value for another, change no digest and break no signature. Version 2 hashes the carried `networkPosture` **object**, so the string, its pinned digest and any further member a producer carries there are all inside the binding. The object the binding covers is the carried one: adding, removing or editing a `networkPosture` member after the arming record is signed derives a binding the producer's own records do not carry. `observationVocabulary` was not an input at all. Its `caught` array decides which labels are caught, and both the recompute and the coverage validity requirements read it, so a producer that narrows the caught set after the run turns a caught row into a clean one. Nothing resisted that: the vocabulary's own digest is verified only against the arrays beside it, so it re-derives for free, and no record's binding moved. Binding the carried digest closes it, since a narrowed vocabulary derives a different run binding and every record then fails the comparison. Version 1 is retired with **no alias and no dual-accept window**. A statement built under it derives a digest no record carries; a record declaring version 1 explicitly covers nothing. ## Predicate ```json { "result": "fail", "observationEnvironment": { "substrate": { "name": "", "digest": { "sha256": "<64-hex-substrate-digest>" } }, "corpus": { "name": "mcp-cve-corpus", "uri": "pkg:probity/mcp-cve-corpus@v1.2", "digest": { "sha256": "f8c41f617cfeddb97292339c877f66b9afa8c201592d6625bb6e0fc7d172a927" }, "manifest": { "classes": { "CO": ["CO-EXFIL-1"] } } }, "catchPolicy": { "digest": { "sha256": "<64-hex-JCS-catch-policy-digest>" } }, "networkPosture": { "posture": "sinkhole", "digest": { "sha256": "<64-hex-JCS-posture-view-digest>" } }, "observationVocabulary": { "digest": { "sha256": "<64-hex-JCS-vocabulary-digest>" }, "labels": ["batch_bound", "canary_bound", "egress_captured", "not_exhibited", "payload_bound", "window_bound"], "caught": ["batch_bound", "canary_bound", "egress_captured", "payload_bound", "window_bound"] }, "runEntropy": { "digest": { "sha256": "<64-hex-run-start-value>" } } }, "coverage": { "assessedClasses": ["CO"], "outOfScope": {}, "routedElsewhere": {} }, "attackResults": [ { "attackId": "CO-EXFIL-1", "containmentObserved": "egress_captured", "basis": "substrate", "method": "intercepted", "actualLayer": "policy.egress_sinkhole", "observationRefs": [0] } ], "observationRecords": [ { "payload": "", "payloadType": "application/vnd.probity.aee-observation.v1+json", "signatures": [ { "keyid": "", "sig": "" } ] } ], "batchRoot": "<64-hex-observation-record-batch-merkle-root>", "doesNotAssert": [ "payload-decryptability without the customer recipient key" ], "executionEnvelope": { "otelTraceId": "<32-hex-w3c-trace-id>" }, "issuedAt": "2026-06-23T16:08:07Z" } ``` The `payload` above base64-decodes to the exact canonical `+json` bytes the substrate signed - the reserved members (`aeeRunBinding`, `aeeKind`, `aeeMethod`) and the readable observation fields live INSIDE it and are read only after the signature verifies (verify-then-read), never as sibling wire fields. The `corpus.digest.sha256` above is real: it is the SHA-256 of the RFC 8785 (JCS) canonicalization of the example's embedded `manifest` object (`{"classes":{"CO":["CO-EXFIL-1"]}}`), which is exactly how a verifier re-derives it. The `observationVocabulary.digest.sha256` is likewise the SHA-256 of JCS `{"caught":[...],"labels":[...]}`. The other digests, `payload`, `keyid`, `sig`, and `batchRoot` are ``. ## Field semantics ### `result` (REQUIRED) One of `fail`, `degraded`, `pass_indirect`, `pass` (lowercase), ordered `fail` < `degraded` < `pass_indirect` < `pass`. The fused outcome, defined as a **total, deterministic, severity-independent** function of the carried predicate (rows + carried vocabulary + coverage maps ONLY - never `observationRecords`, signature outcomes, or consumer policy), pinned by [`recompute-table.json`](recompute-table.json). It is the **minimum**, under that order, of three independent conditions - worst-wins, never evaluation order: 1. **FORCES_FAIL** (contributes `fail`) - some `attackResults` row carries a `containmentObserved` label **in the carried `observationVocabulary.caught`** set, a label **outside the carried `observationVocabulary.labels`** (fail-closed), OR a **missing or out-of-vocabulary `basis` or `method`** (fail-closed, same rule). 2. **COVERAGE_INCOMPLETE** (contributes `degraded`) - `outOfScope` or `routedElsewhere` is non-empty. 3. **INDIRECT** (contributes `pass_indirect`) - some **clean** row (its `containmentObserved` is in the carried labels and not in the carried caught set, and it is fail-closed on neither `basis` nor `method`) declares a `basis` other than `substrate` or a `method` other than `intercepted`. A condition that does not hold contributes `pass`. `pass` is coverage-bounded-observed - a statement about what was assessed, not a guarantee of safety - and `pass_indirect` makes the same coverage claim resting on at least one observation that was indirect in **vantage** (`basis: artifact`, the executed artifact's own account of itself) or indirect in **time** (`method: reconstructed`, derived after the event rather than at it). **Why the fourth value exists.** Without it the top result is reachable by a statement carrying no substrate evidence at all. A party holding the enclosing envelope key but not the substrate's observation key relabels every row clean, moves every row to `basis: artifact`, and drops `observationRecords`, `batchRoot` and `runEntropy` - none of which a non-substrate row requires. What it presents is well formed and used to read `pass`. Measured over every finding-bearing vector in the conformance corpus, that statement is **byte-identical** to one an honest producer with no substrate vantage emits, so no rule over the carried bytes refuses the first without refusing the second, and refusing both would remove the producer whose attack classes (prompt injection, tool selection) have no substrate vantage to observe from. The condition therefore **prices** both below a live interception rather than claiming to tell them apart. **Why it is not phrased over the evidence tier.** The tier is key-relative, and `result` MUST be recomputable from carried bytes alone, so the condition reads the **declared** `basis` and `method` and nothing the tier reads. The cost is stated rather than hidden: an `unattested` substrate clean row still reaches `pass`, and that is the one rank of the clean-row ordering no byte-pure function can express. A consumer crediting any `basis: substrate` row MUST still derive the tier. The default admission threshold is `result == "pass"`. A consumer MAY accept `pass_indirect`, and one that relaxes below `pass` MUST additionally key on each clean row's `basis` and `method` and on that row's derived evidence tier, because below `pass` the ordinal stops distinguishing them. There is deliberately no severity threshold, no policy ruleset, and no free-text reason field: a verifier MUST be able to recompute `result` from the bundle alone, and a producer MUST NOT record a `result` the recompute does not reproduce. A missing expected observation MUST surface as a coverage gap (the affected class moves to `outOfScope` with a reason) - never as an out-of-band degradation - so the recompute stays total. ### `observationEnvironment` (REQUIRED) The digest-pinned containment context the evidence was earned under. Five members are REQUIRED (`substrate`, `corpus`, `catchPolicy`, `networkPosture`, `observationVocabulary`); a sixth, `runEntropy`, is REQUIRED exactly when any row is `basis: substrate`. All digests are 64-character lowercase-hex SHA-256 carried as `{ "sha256": "<64-hex>" }`. - **`substrate`** - an in-toto [ResourceDescriptor](https://github.com/in-toto/attestation/blob/main/spec/v1/resource_descriptor.md) carrying the Attestation A subject reference (`name` + `digest`) of the attested isolation substrate the SUT ran inside. This lets the evidence inherit the substrate's trust: the substrate SHOULD in turn carry its own attestation (e.g. build provenance for the substrate image). - **`corpus`** - a locally typed reference to the attack corpus, not a ResourceDescriptor (see Descriptor typing below): - **`name`** (REQUIRED, string) - the corpus name. - **`uri`** (REQUIRED, string) - a locator, RECOMMENDED as a [purl](https://github.com/package-url/purl-spec) (e.g. `pkg:probity/mcp-cve-corpus@v1.2`). - **`digest.sha256`** (REQUIRED) - the SHA-256 of the **RFC 8785 (JCS)** canonical JSON of the embedded `manifest` object. NOT a hash of the corpus file bytes and NOT producer-asserted: the pre-image is carried in the bundle, so the digest is re-derivable offline with zero fetch. - **`manifest.classes`** (REQUIRED, object) - a map from each assessment-class code to the **complete array of `attackId`s** that class defines. An `attackId` MUST NOT appear under more than one class. This is the coverage denominator at **attack granularity**: dropping a class OR a single attackId changes the manifest hash, which changes `corpus.digest`. - **`catchPolicy`** - an in-toto [ResourceDescriptor](https://github.com/in-toto/attestation/blob/main/spec/v1/resource_descriptor.md) identifying the substrate catch-policy (the JSON that tells the substrate WHAT to catch). Its **`digest.sha256`** (REQUIRED) is the SHA-256 of the RFC 8785 (JCS) canonical JSON of that policy, so an empty/permissive policy (defenses off) is distinguishable from the enforcing one. - **`networkPosture`** - the substrate-authoritative egress posture the run was contained under, sourced from the substrate daemon, not a runner assertion. - **`posture`** (REQUIRED, string) - one of `no_network` (no egress path exists), `allowlist` (egress permitted only to a declared destination set), `sinkhole` (egress is accepted and diverted to a capture endpoint rather than reaching its destination), or `unsafe_bypass_egress` (egress is unrestricted and uninstrumented). The registry is **closed**: an absent, non-string or unregistered value makes the statement malformed, fail-closed. A minor version MAY append a value and MUST NOT redefine a registered one. Closed rather than illustrative because a consumer is invited to coherence-check a substrate row's claimed observation against the posture the run was contained under, and no verifier can decide whether an unregistered posture provides an interception path at a boundary - an open registry would leave that check permanently unreachable while appearing to offer it. - **`digest.sha256`** (REQUIRED) - the SHA-256 of the substrate's non-secret posture view (posture, allowlists, private-IP blocking, interception flags, the host-scoped secret COUNT), canonicalized under RFC 8785 (JCS). It deliberately excludes every secret-bearing field, so it is safe to publish and a secret rotation does not change it while a posture change does. - The run binding folds in the RFC 8785 canonical digest of this **whole object**, so every member carried here is inside the substrate's signature. That is a reason not to extend the object casually: a producer-specific member changes a signed digest, and one added after the arming record is signed invalidates the producer's own statement. - **`observationVocabulary`** - the producer's versioned observation label set, carried on-wire so the recompute and GATE 1 are pure functions of carried bytes and archived attestations remain verifiable after the producer's documentation moves or disappears. - **`labels`** (REQUIRED, array of strings) - the complete array of `containmentObserved` values the producer can emit, **sorted ascending by UTF-16 code unit (RFC 8785 sec 3.2.3) with no duplicates**. Probity's set is `["batch_bound","canary_bound","egress_captured","not_exhibited","payload_bound","window_bound"]`. - **`caught`** (REQUIRED, array of strings) - the subset whose observation constitutes a caught containment event, **sorted ascending by UTF-16 code unit, a subset of `labels`**. Probity's set is `["batch_bound","canary_bound","egress_captured","payload_bound","window_bound"]` - i.e. every label except the clean `not_exhibited`. - **`digest.sha256`** (REQUIRED) - the SHA-256 of the RFC 8785 (JCS) canonicalization of the object `{"caught":[...],"labels":[...]}`. A statement violating the sort, subset, or digest obligations is malformed. The producer's published documentation is commentary on the same vocabulary, never a normative input. (The label spellings are Probity producer vocabulary; a different producer carries its own labels on-wire under this same member, and a consumer treats a `containmentObserved` label outside the *carried* set as fail-closed.) - **`runEntropy.digest.sha256`** (REQUIRED when any row is `basis: substrate`; OMITTED on an artifact-only statement) - the substrate-emitted run-start value folded into the run binding. Its pre-image is the substrate's run-start checkpoint; it is an **anti-splice beacon**, not a freshness nonce. The pre-image SHOULD additionally fold in a publicly datable value that was unpredictable before its round (a drand round output, or an RFC 9334 Section 10.3 epoch identifier), in addition to, never in place of, the substrate-unique component, fetched at arming time (never cached), with the round reference recoverable via the arming payload. A signature over such a value cannot predate its round, so the arming record gains a proven earliest-possible signing time, a floor; `issuedAt` stays the asserted ceiling, deliberately not a two-sided proof. The floor bounds recency only where consumer policy couples the folded round to its freshness window (the producer selects the round), and a beacon inside the producer's own trust domain yields no floor against that producer. Public rounds also make independent consumers' `runEntropy`-reuse observations comparable on a shared time axis. **Descriptor typing (normative).** All six members of `observationEnvironment` are descriptor-shaped and exactly two of them are [ResourceDescriptors](https://github.com/in-toto/attestation/blob/main/spec/v1/resource_descriptor.md); the rule separating them is stated here so a later reader inherits it rather than rediscovers the question. `substrate` and `catchPolicy` identify a resource and carry nothing beside that identity, so they take the framework type. The `sha256` REQUIRED on each is a requirement the descriptor specification explicitly permits a context using the type to impose, and reading a pinned `sha256` off a descriptor is already what this predicate does in its most load-bearing place, since `subject` entries are ResourceDescriptors by the in-toto Statement specification and the run binding reads `subject[0].digest.sha256`. This profile narrows the type on the wire rather than widening it: the schema SSOT carries `additionalProperties: false` on both members, so the descriptor's other optional members (`uri`, `downloadLocation`, `mediaType`, `annotations`, `content`) are NOT admitted here, and no rule in this document would read them. The open standard admits them on a ResourceDescriptor; this structural profile does not, and a statement carrying one on either member is malformed here. The other four members stay locally typed, and the reasons are stated rather than left to inference. **Where a member carries the pre-image its own digest is taken over, that pre-image stays on the statement's own JSON surface**: `corpus` carries `manifest` and `observationVocabulary` carries `labels` and `caught`, and the only descriptor member that could hold either is `content`, whose value is base64. Every byte-level rule above is stated over the statement's JSON - the duplicate-member rule at any depth, the string well-formedness check applied to the raw bytes before any decoded string is read, the nesting bound of 128, and the BMP restriction on canonical surfaces. Material inside a base64 member sits outside all four, so carrying a digest pre-image there would open a SECOND canonicalization boundary inside a signed statement, in a predicate whose whole encoding profile exists so that two conforming verifiers cannot disagree about identical bytes. Where a member instead carries further normative material beside an identity it stays locally typed rather than extending a descriptor with members of its own - `networkPosture` carries the closed `posture` registry and `observationVocabulary` carries the label sets the recompute reads - which is the shape the in-toto `runtime-trace` predicate already uses for `monitor`. `runEntropy` is offered as a reading rather than as a rule: its digest commits to a substrate-emitted run-start value rather than describing a resource, so a descriptor is the wrong vessel for it. ### `coverage` (REQUIRED) Which assessment classes were assessed and how the rest were handled. Together with the digest-committed `manifest.classes` map, this is the coverage bound the verifier checks by set equality at the class level AND by attack-level exhaustion. - **`assessedClasses`** (REQUIRED, array of strings) - the class codes actually assessed in this run. - **`outOfScope`** (REQUIRED, object) - a map from a class code to a human-readable reason it was not assessed (empty `{}` when nothing was out of scope). Its presence makes the run `degraded`, never `fail`. - **`routedElsewhere`** (REQUIRED, object) - a map from a class code to the layer or mechanism that covers it instead (empty `{}` when nothing was routed). Every class code in `assessedClasses`, `outOfScope`, and `routedElsewhere` MUST be a key of `manifest.classes`, the three sets MUST be pairwise disjoint, and their union MUST equal `keys(manifest.classes)` exactly. Additionally (attack-level exhaustion), the `attackId` set of `attackResults` MUST equal the union of `manifest.classes[c]` over every `c` in `assessedClasses`. ### `attackResults` (REQUIRED) One row per corpus attack dispatched. Each row: - **`attackId`** (REQUIRED, string) - the corpus attack/vector identifier; a member of the digest-committed `manifest.classes`. No two `attackResults` rows may carry the same `attackId`; a duplicate is malformed. The attack-level exhaustion check set-compares row `attackId`s, so a duplicate would collapse silently under set semantics; uniqueness is enforced before that comparison. - **`containmentObserved`** (REQUIRED, string) - a label from the carried `observationVocabulary.labels`. The single **normative** reading of this value is its membership in the carried `caught` set; attribution nuance (which caught rung, what evidenced it) is non-normative producer vocabulary that nothing normative reads. Probity's labels: - `payload_bound` - the strongest caught rung: an intercepted payload's commitment matched a corpus-pinned expected artifact hash. (Attribution strength only; non-normative.) - `window_bound` - attributed by the drain-barrier timestamp window. - `batch_bound` - caught during a fuzz campaign, not bound to a specific input. - `egress_captured` - a credential-exfil attempt captured at the sinkhole boundary. - `canary_bound` - attributed by a seeded canary marker surviving in the catch's scrubbed payload. - `not_exhibited` - the clean label: the attack was dispatched but the SUT never exhibited the dangerous behavior, so there was nothing to contain. NOT a containment failure and NOT an evasion. (This is Probity's single clean-label spelling; there is no `no_egress`/`uncontained` alias.) - **`basis`** (REQUIRED, string) - the observation's **vantage**, closed vocabulary `{substrate, artifact}`, fail-closed: - `substrate`: every input the row's claim depends on was obtained at a vantage the executed artifact could neither forge nor suppress (a network boundary, syscall supervision, a hypervisor's read of guest state). An input is artifact-sourced when the claim relies on a channel the artifact can populate arbitrarily without performing the claimed event (its stdout/stderr, exit status, self-emitted logs); an egress capture is NOT artifact-sourced even though the packet bytes were artifact-authored, because the artifact cannot cause the boundary to record an egress without performing one. - `artifact`: at least one input the claim depends on derives from output the executed artifact itself produced. `basis` is the vantage of the claim's **weakest input**: a derived observation inherits `artifact` from any artifact-sourced input it consumed. A producer MUST NOT declare `basis: substrate` on a row it cannot cover under GATE 1 (Coverage validity): such a row is not merely mislabeled, it makes the attestation invalid. - **`method`** (REQUIRED, string) - the observation's **directness**, closed vocabulary `{intercepted, reconstructed}`, fail-closed: - `intercepted`: the claim rests on events captured as they occurred. On a clean row, a live capture vantage was armed for the attack and no capture was attributed to it. - `reconstructed`: the claim derives from state examined after the fact (a snapshot-to-snapshot diff), which can miss a transient raised and undone between the states compared. Like `basis`, `method` composes by weakest input. Post-hoc decode of an event stream captured as it occurred does not demote a row, provided the capture channel was armed for the claimed event class before the event; a row fusing a live capture with after-the-fact state examination is `reconstructed`. `method` describes how the observation was made, not how the row was attributed to its attack. Both fields are REQUIRED on every row and both vocabularies are closed: a missing value, or any value outside them, is fail-closed exactly as an out-of-vocabulary `containmentObserved` label is - the row forces `result` to `fail` and can support nothing stronger. The retired 0.4 values `substrate_observed` / `artifact_reported` / `inferred` are out-of-vocabulary, with no alias (`inferred` has no successor because it conflated the two axes). - **`actualLayer`** (REQUIRED, string) - which enforcement layer acted on the row's containment event, drawn from the registered layer set ([vocabulary registry](vocabulary-registry.md)), OR the literal string **`none`**. A row whose `containmentObserved` is a label-not-in-caught (a **clean row**: nothing acted) MUST carry `none`; `none` is also valid on a caught row and there states the event was observed but no enforcement layer acted (a passive tap, a monitor-only vantage), so enforcement role travels here and only here and `basis` never has to encode who could act. `actualLayer` is REQUIRED on every row; a **missing** member is a MALFORMED statement (a different altitude than the fail-closed row members the recompute reads), not weak evidence. - **`observationRefs`** (REQUIRED on `basis: substrate` rows, array of non-negative integers) - indexes into `observationRecords` binding this row to the records that cover it. An `interception` index MAY be referenced by more than one row. A producer MUST NOT reference a record from a row whose attack the record's committed payload does not evidence. On a row declaring `attribution: pinned` that obligation is checkable and is checked, by the coverage validity requirement stated above: the corpus declares what the attack's interception commits to and the verifier compares. On a row declaring `paired` it remains an obligation outside every gate, because no validity requirement, recompute input or tier evaluation reads it there, and a conforming verifier neither can nor may invent an evidencing heuristic in its place. The line between the two is exactly the line the corpus draws by carrying an expectation or not. `arming` / `sealed` / `examination` indexes are run-level and MAY be shared by every row earned under them. Wherever `observationRefs` is present - on any row, regardless of `basis`, and including rows on which nothing normative reads it - every index MUST be in range for `observationRecords`. An out-of-range index is a structural integrity fault that makes the statement malformed, fail-closed and independent of any gate, so a reference that does not resolve is never silently ignored. - **`observationSelectors`** (OPTIONAL, array of strings) - producer-defined tokens positionally parallel to `observationRefs`, each naming the sub-observation within a referenced record's payload this row rests on. Token content is producer vocabulary; nothing normative reads it, and selector presence or absence changes no gate outcome. **Consumer strength orderings (non-normative guidance; not part of the recompute).** The two axes bind a consumer's confidence on opposite sides. `basis` bounds a `fail` over its supporting rows (those forcing `result` to `fail`): a `fail` whose supporting rows are all `artifact` is a weaker claim than one carrying a `substrate` row, and a consumer MAY reject it; a `fail` from a (`substrate`, `reconstructed`) row is still an observation the artifact could not manufacture, weaker than an interception only in that it was derived after the fact. `method` bounds a `pass`: a `pass` whose clean rows are all `attested` at (`substrate`, `intercepted`) - each covered by both the run's `arming` record and a covering `sealed` record - makes the strongest absence claim this predicate can carry; a `pass` resting on any `reconstructed` clean row tolerates transients between the observed states; a `pass` resting on any `artifact` clean row, or an `unattested` substrate clean row, is self-reported absence, the weakest. Only rows deriving `attested` occupy the `substrate` rank; an `unattested` substrate row ranks with `artifact` in both orderings. A consumer **MAY reject, NEVER downgrade**: declining to credit a rank is not a rewrite of signed bytes. **Field partition (delta-I: which identity's signature backs which field).** Substrate-covered - through GATE 1 and the evidence tier: `basis` and `method` on rows deriving `attested`, and the content of every verified observation record. Producer-asserted - backed only by the enclosing envelope: `containmentObserved` labels and their attribution nuance, `basis`/`method` on `artifact` rows, `actualLayer`, `coverage`, `doesNotAssert`, and the assembly of the predicate itself. The two are not composable: a per-observation property the recompute or the documented consumer gating reads travels ON the row as a required member (with a closed vocabulary, fail-closed on missing or unknown values); run-level pins in `observationEnvironment` never substitute for a row-level property, and the run-level `arming`/`sealed` instruments back a clean row's claim *through* the row's own `observationRefs` without substituting for a row member. Which keys count as substrate observation keys is consumer key policy; the substrate observation key MUST NOT be accessible to the subject artifact and SHOULD be held apart from the producer's assembly plane. Where it is so held, the tier defeats a pipeline with no substrate in the loop, cross-configuration splices, record drops, and method inflation; where one party holds both keys (the single-root deployment), the tier instead defeats only a keyless downstream tamperer - a substrate operator who signs false evidence remains outside this predicate's threat model, as for every self-asserted field. ### `observationRecords` (OPTIONAL) The signed observation records the containment rows bind to. OPTIONAL: an artifact-only statement carries none (and then carries no `batchRoot`). **Each entry is a standard [DSSE](https://github.com/secure-systems-lab/dsse) envelope** carrying the EXACT signed bytes of one observation - there is NO parallel readable object; the readable fields live inside the signed `payload` and are read only after the signature verifies. Each entry: - **`payload`** (REQUIRED, string) - the **base64 (RFC 4648 §4 standard alphabet)** of the exact canonical `+json` bytes the substrate signed. Any record used to cover a `basis: substrate` row MUST base64-decode to a JSON object that is canonical per RFC 8785 and valid I-JSON per RFC 7493, carrying these reserved top-level members (a record whose payload is not so parseable, or whose media type is not `+json`, covers nothing): - **`aeeRunBinding`** (string) - the run binding digest defined under Run binding. - **`aeeKind`** (string) - one of: - `interception` - per-event capture; covers **caught** rows. `aeeMethod` MUST be `intercepted`. - `arming` - run-level: a live, cooperation-independent capture vantage was armed for the run before corpus injection. Payload MUST carry `armedAt` (under the timestamp profile `issuedAt` defines, no later than `issuedAt`) and `aeePostureDigest` (equal to the pinned `networkPosture` digest); `aeeMethod` MUST be `intercepted`. Covers **clean** rows (with a covering `sealed` record). - `sealed` - run-level: the vantage stayed armed to run-end. Payload MUST carry `aeeStillArmed` (boolean, MUST be `true` to cover), `aeeDropCount` (integer; MUST be `0`, or `0 <= aeeDropCount <= aeeDropBound` where `aeeDropBound` is a producer-declared integer in the SAME payload), and `aeePostureDigest` (equal to BOTH the pinned `networkPosture` digest AND every covering `arming` record's `aeePostureDigest`); `aeeMethod` MUST be `intercepted`. Covers **clean** rows (with an `arming` record). - `examination` - the substrate examined artifact-independent state after the fact. `aeeMethod` MUST be `reconstructed`. Covers **reconstructed** rows. - **`aeeMethod`** (string) - `intercepted` or `reconstructed`, how the substrate observed, stated inside the signature (it caps the row's `method`, see GATE 1). An `arming` record's payload MAY additionally carry three reserved members that chain runs under the same substrate key: `aeeRunSeq` (a positive safe-range integer), `aeePrevRunBinding` (the lowercase 64-hex run binding digest of the predecessor run, absent exactly when `aeeRunSeq` is `1`), and `aeeChainScope` (the population the sequence counts, declared as a duplicate-free array of dimension tokens drawn from the closed vocabulary registered below, sorted in the same canonical order as `observationVocabulary.labels` (UTF-16 code-unit order, RFC 8785 section 3.2.3); REQUIRED whenever `aeeRunSeq` is present). The chain is always structurally under one substrate key; each token names a further within-key partition attribute already carried elsewhere in the attestation and fixes where a consumer reads that attribute's value. The declared array is the *dimension set*; the *evaluated tuple* is the projection of the substrate-key value and each declared token onto its registered attribute value for this run (computed, never carried). The recommended minimum is `["subject"]`; the empty array is the single global per-key counter that makes every rule below vacuous and leaks the producer's total run volume across customers. The `aeeChainScope` vocabulary is closed and each token pins a projection to a value already carried on the wire: `subject` to `subject[0].digest.sha256`, `corpus` to `observationEnvironment.corpus.digest`, and `networkPosture` to `networkPosture.digest.sha256`. The substrate key is the structural outer axis and is never a token. Values are not carried in the member; a consumer projects each declared token onto its registered field for this run. Minor versions MAY append tokens (each with a pinned projection) and MUST NOT redefine an existing one; an unrecognized token fails closed, as every closed vocabulary in this spec does. Within one attestation these members are syntax-checked in the reserved-member walk and nothing else normative reads them (GATE 1, the recompute, and GATE 2 are unchanged). A violation of the syntax rules - a non-positive or non-integer `aeeRunSeq`, a malformed `aeePrevRunBinding`, a missing `aeeChainScope` when the sequence is present, a non-array `aeeChainScope`, an array carrying a token outside the registered vocabulary, an array not in canonical order (the same canonicality rule as `observationVocabulary.labels`: UTF-16 code-unit order, duplicate-free), or any of the three present without `aeeRunSeq` - is handled as any reserved-member violation: the record covers nothing. Across attestations these members are consumer policy over whatever set the producer publishes. A consumer compares each attestation's declared dimension set against the set its policy demands: an equal set is admissible; a strictly finer set (a superset of dimensions) is scope-narrowing, fragmenting every run into a singleton chain so no gap, fork, or duplicate genesis can arise and the chain proves nothing; a strictly coarser set (a subset of dimensions) pools distinct subjects, so a withheld run of the demanded subject is deniable as a sibling's private run and a sibling's run can occupy the withheld sequence position. A consumer that has demanded a scope admits only the equal set, neither finer nor coarser. Among admitted attestations the rules key on the evaluated tuple, not the token set: a skipped `aeeRunSeq` under one tuple is a gap; two under one tuple with equal `aeeRunSeq` are a fork; a shared `aeePrevRunBinding` is a shared predecessor; two genesis records (absent `aeePrevRunBinding`) under one tuple are equivocation of the same grade as a shared predecessor. Keying on the tuple is load-bearing: genesis-per-subject-value is the normal case, and only a second genesis under an identical tuple is a reset. A chain reset is not a fresh start. The members claim ordering under the substrate key, nothing more (commit-before-outcome holds only combined with the run-entropy floor or an external registration receipt); a numeric gap is unexplained absence, never fraud evidence in itself (crashed, private, and discarded runs all produce gaps innocently). Even a contiguous, fork-free, correctly-scoped chain does not prove population completeness: a producer may still mint a dense, gap-free set of passing runs after the fact. Fork consistency among the published set is the ceiling of what any self-contained attestation set establishes; the demand-disclosure yield is that a consumer policy MAY require a contiguous, fork-free chain over the runs offered to it. The external completion is a registration receipt (committing each arming record to an append-only transparency log at run start, e.g. SCITT, RFC 9943, with COSE receipts, RFC 9942), deliberately outside this predicate. A record violating any constraint of its declared `aeeKind` covers nothing, and the constraints are read on every carried record of a covering kind rather than only on the records some row resolves. A constraint evaluated only where a row points is a constraint whose subject the producer chooses: a substrate signs a `sealed` record reporting its moat down, the producer carries that record and points the row at a second seal, and the run reads clean with the record that says otherwise sitting in the statement and inside `batchRoot`. A record whose `aeeKind` the consumer does not recognize covers nothing and is otherwise ignored, while still contributing its leaf to `batchRoot` - an unrecognized kind can only weaken, never strengthen, a row (candidate future kinds, informatively: a hardware-quote kind binding the vantage to a measured platform, and a `registration` kind carrying a transparency-service receipt over the arming record). The `aee` member prefix is reserved for future versions; everything else in the payload stays producer territory. Reserved members inside a producer-defined signed payload follow the registered-claims lineage (JWT RFC 7519 Section 4; EAT RFC 9711 in the RATS family; OCI annotation prefix reservation; the `+json` rule per RFC 6839 Section 3.1), informative citations locating the pattern, not importing any cited standard's rules, with two deliberate departures: unrecognized or colliding `aee*` members are fail-closed (they can only weaken coverage, never create it) where JWT ignores unknown claims, and verify-then-read is normative here. What an `interception` record carries is a commitment to an intercepted payload rather than the payload itself, keeping the attestation publishable rather than a sensitive-data store. The Probity producer's concrete observation-payload field profile (the readable fields beside the reserved members) is specified in [catch-record.md](catch-record.md). - **`payloadType`** (REQUIRED, string) - a **producer-defined media type ending in `+json`**. It is the second input to the PAE pre-image, so a differing value changes the signed bytes. The Probity producer emits the constant `application/vnd.probity.aee-observation.v1+json`. - **`signatures`** (REQUIRED, array, `minItems: 1`) - each element `{ "keyid": , "sig": }`. The signature is over `PAE(payloadType, b64decode(payload))` (DSSE Pre-Authentication Encoding). A record verifies when at least one element validates under a pinned key. A `keyid` is an unauthenticated lookup hint that selects WHICH pinned key to try, never the check itself. **Verify-then-read.** A verifier processes each covering record as: (1) validate a `signatures[]` element over `PAE(payloadType, b64decode(payload))` under the pinned substrate observation key; (2) `record = json.loads(b64decode(payload))`; (3) assert the decoded payload is its own RFC 8785 (JCS) canonical form and valid I-JSON; (4) read the reserved members and observation fields from `record`. All bindings (run binding, kind constraints, method cap) operate on the DECODED record, never on wire siblings. ### `batchRoot` (REQUIRED when `observationRecords` is non-empty) An RFC 6962 Merkle root over the observation records, SHA-256, with **domain-separated hashing**: each leaf is `H(0x00 || the record's DSSE PAE bytes)`, each internal node is `H(0x01 || left || right)`, the tree built by the **RFC 6962 recursive largest-power-of-two split** - never by duplicating a trailing node to pad the leaf count - leaves in `observationRecords` array order, a single-record tree's root its leaf hash, and an empty array with no root. **Two byte-identical entries make the attestation invalid** (duplicate-leaf REJECT: a record's canonical identity is its leaf hash). Carried once at the predicate level; a `batchRoot` that does not recompute over the carried records makes the attestation invalid, and a `batchRoot` present with no records is likewise invalid. Because a clean run's committed set includes its `arming` and `sealed` records, absence evidence cannot be dropped without changing the root. > **Fold note (batch-root n=5 non-divergence).** An earlier concern held that the naive > "promote the unpaired odd node" fold **diverges** from the RFC 6962 recursive split at five leaves. > That premise is **FALSE**: the two folds were empirically verified byte-identical for every leaf > count on all Probity rails. The RFC 6962 recursive split is nonetheless pinned as the single > normative construction, and the only v0.6 delta to the batch-root rule is the **duplicate-leaf > reject** plus the **PAE-bytes leaf content** (`H(0x00 || PAE(payloadType, payload))`, where v0.3 > hashed the raw canonical bytes); the fold arithmetic itself is unchanged and is NOT rewritten on any > rail. > **Why "never duplicate" is load-bearing, precisely.** CVE-2012-2459 (Bitcoin, 2012, verified against > `bitcoin/bitcoin`'s own `src/consensus/merkle.cpp` commentary and the Bitcoin Optech writeup) is a > Merkle-tree malleability / invalidity-caching netsplit: pad an odd transaction list by *duplicating* > the last entry, so an invalid block (the duplicate double-spends) shares a root with the valid block > it was built from, and a node that caches the invalid form by root refuses the valid form until > restart. It is **not** a second-preimage attack on the hash function — that is a separate property, > and the one the `0x00`/`0x01` domain-separation prefixes above defend. The odd-node rule here defends > against duplication-based root collisions specifically: promotion carries the unpaired leaf's digest > forward *unchanged*, so an odd batch of N records and a genuinely-duplicated batch of N+1 records > (the N+1th a byte-identical copy of the Nth) never share a root — and the duplicate-leaf REJECT rule > above closes the remaining case, an honestly-even batch that happens to carry two identical entries. ### `doesNotAssert` (OPTIONAL) A machine-readable **negative-claims list**: short strings stating what this evidence explicitly CANNOT claim, so a consumer does not over-read it (e.g. a `--recipient` run states it does not assert payload-decryptability without the customer key). Advisory: a verifier MUST NOT require it and nothing in it participates in the recompute or the binding rules. `doesNotAssert` is the **single canonical spelling**; the earlier snake_case `does_not_assert` is rejected with no alias (two accepted spellings would mean two canonicalizations for the same content). Absent when the producer asserts no explicit negative claims. ### `issuedAt` (REQUIRED) When the producer signed this evidence bundle, carried as the in-toto framework's [`Timestamp`](https://github.com/in-toto/attestation/blob/main/spec/v1/field_types.md) field type. REQUIRED in v0.6 (was OPTIONAL in v0.3). Distinct from the observation timestamps inside `observationRecords`; an `arming` record's `armedAt` MUST be no later than `issuedAt`. Its purpose is also consumer-side **freshness gating**: an admission controller MAY deny evidence older than its window, and a consumer's policy MAY bound a named substrate observation key with a validity window checked against `issuedAt`. **Timestamp profile (normative).** `Timestamp` requires RFC 3339 in the UTC timezone, and this predicate pins the two choices that type leaves open. A statement is canonicalized and digested as its bytes, so no verifier may normalize the field before reading it and the admissible set has to be written down; left open, one rail is quietly stricter than another and the divergence surfaces only when a statement crosses between them. The date-time separator and the zone designator MUST be uppercase - never the lowercase `t` and `z` that [RFC 3339](https://www.rfc-editor.org/rfc/rfc3339) also admits - and the zone designator MUST be `Z`, `+00:00`, or `-00:00`, never a non-zero offset such as `+05:00`. `-00:00` is admitted rather than excluded because RFC 3339 section 4.3 gives that spelling the meaning that the instant in UTC is known while the offset to local time is not, which describes where the producer stood and not when it signed, and the instant is the only thing this predicate reads from the field. A statement whose `issuedAt` is absent, is not RFC 3339, or is RFC 3339 outside this profile is malformed. `armedAt` carries this same profile, defined here and cited from the arming record so the two fields cannot drift apart. ### `executionEnvelope` (OPTIONAL) The tool-call OTel correlation envelope. When present, **`otelTraceId`** (REQUIRED within, 32-char lowercase-hex W3C trace-id) is the only gated key; a cross-runtime emitter MAY attach further correlation fields. OPTIONAL for producer neutrality. ### `_ext` (OPTIONAL) The in-toto extension-field convention: a free-form object for producer-scoped extensions that are deliberately **out of core**. A verifier MUST NOT require `_ext` and MUST ignore extensions it does not recognize; nothing in `_ext` participates in the recompute or the binding rules. No predicate member (including any `_ext` sibling) may begin with the reserved `aee` prefix. #### `_ext.run_fidelity` (Probity extension, OPTIONAL) The run-level rollup of the per-observation **EvidenceFidelity** signals - the aggregate CONFIDENCE and COMPLETENESS of the run's attribution, so a buyer's OPA/rego policy can gate on evidence QUALITY, not just structural validity. It is Probity-namespaced: a generic verifier ignores it; a Probity verifier MAY apply the OPTIONAL recompute rule below. Shape: - **`clean_drain`** (bool) - true when the run's final signed `quarantine.coverage` observation record closed with `status == "final"` and `in_flight == 0 && queued == 0`: the arm provably drained every admitted catch, so no attribution was silently lost mid-run. - **`task_resolution_counts`** (object) - a HISTOGRAM (never a min) over the per-catch owning-task resolution mode: `{ "4-tuple": n, "dst-fallback": n, "dst-only": n }`, counted across the run's `vmi.attribution` observation records. - **`verity_device_pairing`** (string, OPTIONAL) - the run-level dm-verity device-pairing confidence (`verity-exact-root-digest` or `verity-heuristic-size`), riding the final signed `quarantine.coverage` record's `coverage.verity_device_pairing`; present ONLY on a verity-anchored run. A `host-block`-sourced (copied-up) ModelBOM entry is authoritative over this run scalar for its own attribution (see [ml-bom.md](ml-bom.md)). All three fields are RE-DERIVABLE from the signed `observationRecords` alone. **Optional recompute rule (Probity verifier).** `_ext.run_fidelity` is NEVER trusted as asserted: a Probity verifier RE-DERIVES all three from the verified observation records (which ride inside `observationRecords`, covered by `batchRoot`) and asserts equality, so a producer cannot inflate its own evidence quality. An absent `_ext.run_fidelity` is sound; a PRESENT one that does not match the recompute is rejected. ## Verification (third-party, offline, two gates) Two expectations are consumer policy, resolved outside the attestation and never read from it: which keys count as substrate observation keys (GATE 2's input), and which corpus and substrate this consumer expects. A consumer MUST pin, out of band, the `expectedCorpusDigest` and `expectedSubstrateDigest` for the deployment it is admitting into (a bundle's internal exhaustion is meaningless unless the consumer pins WHICH corpus and substrate it was earned under) and assert them at the consumer-policy step after GATE 2. The anchor comparison is deliberately not a validity gate: GATE 0, GATE 1, and the recompute are functions of carried bytes alone and hold identically for every consumer, while the expected corpus and substrate differ per consumer. An anchor-mismatched attestation is valid evidence about the wrong context, and it is NOT ADMITTED, exactly as an attestation whose covering signatures fail is not admitted. Every verification surface exposes one consumer-facing admission result, `admitted` = validity AND tier-policy satisfaction AND anchors-satisfied-when-supplied, and binds its exit status to `admitted`, never to bare validity, so a result-only consumer cannot read a valid-but-wrong-context attestation as admissible. The exit binding applies whenever a consumer policy or anchors are supplied; a bare conformance replay with no policy binds to validity alone; with no policy there is no admission decision to bind. After the envelope checks in [signing-envelope.md](signing-envelope.md), a verifier - with zero network access - proceeds through GATE 0, GATE 1, the recompute equality, and GATE 2 (the four byte-pure steps first, then the trust-relative stage). ### GATE 0 - statement well-formedness The parsing rules and field shapes: the predicate is its own RFC 8785 (JCS) canonical form and valid I-JSON (no duplicate members, no unsafe integer); the closed registries (`actualLayer`, `networkPosture.posture`) validate, while `containmentObserved`/`basis`/`method` vocabulary membership is deferred to the recompute gate (fail-closed on an out-of-vocabulary or absent value), not gated here; `subject` has exactly one entry with a lowercase-64-hex `sha256`; each run-binding digest input read verbatim is lowercase 64-hex, and so is `networkPosture.digest.sha256`, which version 2 of the binding no longer reads verbatim but which is still compared byte for byte against a record's `aeePostureDigest`; `issuedAt` is present and inside the timestamp profile; a `basis: substrate` statement carries `runEntropy`; and `actualLayer` is present on every row (a missing member is malformed here, not a fail-closed row). Coverage integrity is checked here: canonicalize `manifest` under JCS, hash, assert equality with `corpus.digest.sha256`; assert the class sets are pairwise-disjoint and set-equal to `keys(manifest.classes)`; and, at ATTACK granularity, assert the `attackId` set of `attackResults` equals the union of `manifest.classes[c]` over `c in assessedClasses`. Canonicalize `{"caught":[...],"labels":[...]}` and assert equality with `observationVocabulary.digest.sha256`, and assert `labels`/`caught` are sorted ascending by UTF-16 code unit, dupe-free, and `caught` a subset of `labels`. ### GATE 1 - coverage validity (RATS "Appraisal Policy for Evidence") A **key-independent consumption precondition** computed from carried bytes (reads record payloads, but never signatures or consumer policy). A consumer that consumes `result`, credits any row, or applies either strength ordering MUST evaluate GATE 1 first; on failure the attestation is INVALID and its `result` MUST NOT be consumed - the same handling as any malformed statement. For every `basis: substrate` row: 1. `observationRefs` is non-empty and every index is in range for `observationRecords`. 2. **Class-match:** a caught `intercepted` row references at least one `interception` record; a `reconstructed` row references at least one `examination` record; a clean `intercepted` row references at least one `arming` record AND at least one covering `sealed` record. A row fail-closed on `containmentObserved`, `basis`, or `method` cannot class-match and is therefore INVALID. 3. Every referenced payload parses as a canonical `+json` I-JSON object carrying the reserved members, and its `aeeRunBinding` equals the run binding derived from this statement. 4. The row's `method` is no stronger than the weakest signed `aeeMethod` across its COVERING records (records that cover nothing do not participate in the cap). 5. `batchRoot` recomputes over `observationRecords` (RFC 6962, domain-separated, duplicate-leaf reject) - a statement-level check run whenever records are present. 6. Every carried record that binds to this run and whose `aeeKind` names a covering kind (`interception`, `arming`, `sealed`, `examination`) satisfies every constraint of that kind, whether or not any row resolves an index to it - a statement-level check, and the universal partner of the class-match rule above, which asks only whether a covering record is present and so says nothing about the failing ones carried beside it. The kinds registered as covering nothing and the kinds a verifier does not recognize are unaffected: neither carries a constraint that could be violated. ### Recompute equality Re-derive `result` by the [`recompute-table.json`](recompute-table.json) reduction over the rows, the carried vocabulary, and the coverage maps, and assert it equals the recorded `result`. This is a function of the carried predicate alone; it never reads `observationRecords`, signature outcomes, or consumer policy, so a `result` that varied with the consumer's trust anchors would not be recomputable. ### GATE 2 - evidence tier (RATS "Appraisal Policy for Attestation Results") Given a valid attestation, a consumer MUST - before crediting any `basis: substrate` row or applying either strength ordering - derive a per-row **evidence tier**: a `basis: artifact` row (and a row fail-closed on `basis`) is `declared`; a `basis: substrate` row is `attested` when every covering record's signature verifies against a key the consumer's policy names as a substrate observation key, and `unattested` otherwise. **No TOFU:** a consumer with no policy-pinned substrate root MUST treat every `basis: substrate` row as `unattested` and MUST NOT infer the substrate root from the predicate. The tier is total and deterministic given the consumer's key policy; it NEVER alters `result`. A carried predicate member named `evidenceTier`, or any predicate-level member beginning with the reserved prefix `aee`, MUST be ignored and MUST NOT alter the derivation. Finally the consumer-policy step: when the consumer supplies anchors (it MUST for admission decisions), assert `corpus.digest.sha256 == expectedCorpusDigest` and `substrate.digest.sha256 == expectedSubstrateDigest`; a mismatch fails `admitted`, never validity. Confirm the `subject` digest is the SUT image the verifier intends to admit and surface posture + coverage + the per-row tier for the consumer's admission policy. The surfaced report carries both the byte-pure facts (validity, `result`) and the consumer-relative facts (tier, anchor comparison) with `admitted` as their conjunction. No live execution environment and no network access are required. ## Named non-claims - **COV-1 (coverage-bounded-observed).** A `pass` is a statement about what was assessed under the pinned corpus and catch policy; it makes no general safety claim. - **COV-2 (run-population completeness is out-of-wire).** A clean `(substrate, intercepted)` row's `pass` is a claim about the run THIS attestation carries - that a live vantage was armed and stayed armed and no interception was attributed to the row - never a claim about a run population. Nothing in this predicate proves that other runs of the same configuration did not occur or were not withheld; run-population completeness (via a run ledger or monotonic counter) is a consumer or policy concern. - **COV-3 (no cross-predicate composition).** This predicate makes no cross-predicate claim: composing it with a sibling execution predicate (for example a runtime trace of a different execution) does not yield end-to-end coverage, and a consumer MUST NOT infer a composite guarantee unless its policy binds both attestations to the same execution (a shared subject digest and run identifier). ## What a verifier can and cannot independently check (honesty) - A verifier CAN confirm the evidence is authentic, bound to its subject image, and signed by a trusted key; recompute the `result`, the coverage-integrity binding, and the on-wire vocabulary digest; evaluate GATE 1 (coverage validity) from the carried bytes and derive the GATE 2 tier against its own key policy; validate each observation record's DSSE signature and the drop-evident batch root; and read the observation environment exactly as recorded. - A verifier CANNOT, from the evidence alone, confirm that the corpus content behind `corpus.digest` is genuinely adversarial (it confirms the *manifest* commitment and checks corpus identity against a consumer-pinned digest, not corpus *quality*), nor that the assessment taxonomy is complete. - A verifier CANNOT distinguish a **genuine evasion from a benign no-op** beyond what the substrate's sensors observed under the pinned catch policy: it trusts the substrate's sensor efficacy and the producer's taxonomy, and is honest only within the coverage the anchors pin. - Coverage is only as trustworthy as the named substrate observation key's un-compromised lifetime. Where one party holds both the assembly and the observation key (the single-root deployment), that single trust root is a single point of total failure: a **compromised or dishonest key-holder** can mint any coherent bundle, as with every signed attestation. The soundness rules constrain what a validly-signed bundle can *claim and still verify*, not what a key-holder can sign; rejecting an under-defended posture (`unsafe_bypass_egress`) is the consumer admission policy's job. ## Related standards - **[in-toto attestation](https://github.com/in-toto/attestation)** - this predicate is proposed in the in-toto attestation namespace as the **active-stimulus sibling** to the passive `runtime-trace` family: `runtime-trace` carries unbounded, monitor-specific logs; this predicate carries a bounded adversarial campaign with per-attack rows, an on-wire observation vocabulary, and a cryptographic coverage bound. - **[RATS (RFC 9334)](https://www.rfc-editor.org/rfc/rfc9334.html)** - the two-gate structure is the RATS appraisal split: GATE 1 (coverage validity) is the Verifier's key-independent **"Appraisal Policy for Evidence"**; the per-row evidence tier is the Relying Party's trust-relative **"Appraisal Policy for Attestation Results"**, and the tier names `attested`/`unattested`/`declared` align to that vocabulary rather than minting bespoke terms. - **in-toto training / build provenance (downstream join)** - a `vmi.attribution` observation record carries a per-model ModelBOM committed by the record's decoded `enrichment_digest`, whose entries follow the [ml-bom.md](ml-bom.md) raw-file-SHA-256 identity contract: an in-toto training-provenance attestation whose subject is the weight file keyed by the same raw-file hash chains to this evidence by digest equality, with no shared naming and no live environment. - **in-toto `test-result`** - the fused-`result` precedent: a REQUIRED `result` enum recomputable from the carried lists. This predicate follows the same recomputable-summary discipline. - **SVR / [SLSA VSA](https://slsa.dev/spec/v1.0/verification_summary)** - deliberately NOT this predicate (see Purpose). A consumer MAY emit an SVR/VSA downstream over this evidence, binding it by the evidence's DSSE payload digest; this predicate itself carries no delegated policy decision. - **[DSSE](https://github.com/secure-systems-lab/dsse)** - the envelope, used at TWO layers: the outer statement envelope (see [signing-envelope.md](signing-envelope.md)) AND each `observationRecords[]` entry, which is itself a DSSE envelope over one observation payload (`payloadType` `application/vnd.probity.aee-observation.v1+json`). Reusing the standard shape keeps the inner records `cosign verify-blob`-compatible and gives every rail one verify path. - **[SCITT](https://datatracker.ietf.org/wg/scitt/about/)** - a future transparency-log inclusion proof (a COSE **Receipt**) attaches at the DSSE **envelope** layer as a sibling to `signatures`, NOT in this predicate body; the envelope is the correct staple point and the predicate schema stays unchanged. ## Versioning This is Adversarial Execution Evidence v0.7 (predicate type URI suffix `/v0.7`). Adding a new OPTIONAL field - including additive `attackResults` optionals, `doesNotAssert` entries, and `_ext` extensions - is additive and stays within the version; a verifier ignores fields it does not recognize, and an unrecognized observation-record `aeeKind` covers nothing and is otherwise ignored (fail-closed forward compatibility). A rename or removal of a field, a change to the DSSE observation-record shape or the `+json` media-type rule, a change to the `containmentObserved` / `basis` / `method` / `posture` vocabularies, a change to the run-binding construction (`aeeBindingVersion`), or a change to either appraisal gate is breaking and requires a new version URI. A member is born exactly when a normative reader consumes it: if a future version makes the shared-reference evidencing obligation checkable, attribution strength acquires a normative reader at that version and becomes a required member then, not retroactively and not through a verifier-invented heuristic in the meantime. ## Changelog - **2026-07-29** - Run binding moved to `aeeBindingVersion: 2`, and the posture registry closed normatively. The pre-image gains `observationVocabulary`, the carried vocabulary digest, so narrowing the caught set after the run breaks every record's binding instead of re-deriving for free; and its `networkPosture` input becomes the RFC 8785 canonical digest of the carried `networkPosture` **object** rather than the value of that object's own `digest` member, bringing the posture string inside the signature it had been sitting beside. Both inputs are configuration already on the wire, so the change costs no bytes and adds no comparison - each closes through the equality every record's `aeeRunBinding` already runs - and both are fixed before corpus injection, which is the admission test for any binding input, since the arming record carries the digest and is signed before injection. Version 1 is retired with **no alias and no dual-accept window**; the absent-member default is now the implemented version rather than a fixed number, so omitting the optional declaration stays legal across a version change. Separately, `networkPosture.posture` is stated **closed** at its four registered values with an unregistered value malformed, resolving a divergence in which the upstream prose introduced the values as an example while the proto beside it, this document, the producer, both binding surfaces and the shipped admission policy all treated them as a closed, fail-closed set. New failure code: `posture-vocabulary`. - **2026-07-29** - v0.6 timestamp profile and descriptor typing (spec text; no wire-shape change, no signed byte, digest, signature, or conformance vector moves). `issuedAt` is typed as the in-toto framework's `Timestamp` rather than as a bare RFC 3339 string, and the profile that type leaves open is pinned on the field: uppercase date-time separator and zone designator, and a zone designator of `Z`, `+00:00`, or `-00:00`. The zone rule was previously written only on `armedAt`, so a statement whose `issuedAt` carried `+05:00` was conformant here while being off-guideline, and the case rule was written nowhere; `armedAt` now cites the profile instead of restating half of it. The schema SSOT carried the field as `format: date-time`, which is annotation-only under the default draft 2020-12 vocabulary and so asserted nothing; it now also carries a `pattern`, which every conforming validator enforces, so the schema states the profile rather than merely describing it. `observationEnvironment.substrate` and `observationEnvironment.catchPolicy` are stated to be ResourceDescriptors, `corpus` is stated NOT to be one, and the rule holding the remaining four members is written down: a member carrying the pre-image its own digest is taken over keeps that pre-image on the statement's own JSON surface, because the only descriptor member that could hold it is base64 `content` and material inside a base64 member sits outside every byte-level rule this document states. This profile narrows the descriptor rather than widening it - the schema SSOT's `additionalProperties: false` excludes `uri`, `downloadLocation`, `mediaType`, `annotations`, and `content` on both members. - **2026-07-28** - v0.6 vetting rounds 7-9 (spec text; no wire-shape change). `aeeChainScope` is a duplicate-free array of registered dimension tokens (UTF-16 code-unit sorted) with a two-sided equality gate and fail-closed rejection of a non-array, an unregistered token, or a non-canonical order, replacing the free-form producer string that left scope-narrowing ungateable; the gap, fork, and genesis rules key on the evaluated tuple while consumer policy compares the declared dimension set. An arming payload MAY carry a read-first `aeeBindingVersion`, rejected fail-closed when unimplemented and never driving the derivation. The whole statement is parsed as strict I-JSON (a duplicate member anywhere is malformed) and every string literal MUST be a well-formed sequence of Unicode scalar values (valid UTF-8, no overlong form, no CESU-8, paired surrogate escapes, no raw control below U+0020, four-hex `\u`), checked on the raw bytes before any decoded string is read. A normative JSON nesting-depth bound of 128 is stated with its counting rule (open containers, the outermost brace is depth 1). `armedAt` requires a zero UTC offset, an out-of-range `observationRefs` index is a fault on any row, duplicate `attackId` rows are malformed, the single-subject requirement applies on a statement of any basis, and the three coverage sets are a disjoint partition. The fork-consistency ceiling gains its demand-disclosure yield (a consumer policy MAY require a contiguous, fork-free chain). No vector-forced reading is unpinned: agreement on a rule no vector exercises is untested rather than confirmed. Strict I-JSON also excludes the Unicode noncharacters (U+FDD0..U+FDEF, U+nFFFE/U+nFFFF) per RFC 7493 section 2.1, and the JSON nesting bound now charges a level per open container so an empty-container leaf is counted at its own depth. - **2026-07-23** - v0.6 review revisions (spec text; no wire-shape change beyond optional additive members). BMP-only upgraded from producer hygiene to a verifier rejection obligation, with the UTF-16 sort rule retained as defense in depth. External anchors rescoped from verifier inputs to consumer-policy obligations with a single conjoined `admitted` admission result (validity AND tier policy AND anchors); the anchor comparison is not a validity gate. Run-entropy pre-image gains a SHOULD for a publicly datable, round-unpredictable component (proven signing-time floor; asserted ceiling unchanged). Optional `aeeRunSeq` / `aeePrevRunBinding` / `aeeChainScope` arming-payload members for cross-run gap evidence (ordering-only; equivocation semantics; registration-receipt completion, SCITT RFC 9943 / COSE receipts RFC 9942). Shared-reference evidencing restated as a producer obligation outside every gate; selectors do no covering work. Registered-claims precedent note (RFC 7519, RFC 9711, OCI, RFC 6839) with the fail-closed and verify-then-read departures stated. - **v0.6** - Two-gate substrate backing for `basis: substrate`. Byte-checkable **coverage validity** (references resolve in range and class-match; every covering payload is canonical `+json` carrying the reserved members with `aeeRunBinding` equal to the derived run binding; `method` capped by the weakest signed `aeeMethod`; `batchRoot` recomputes) is a VALIDITY requirement and a consumption precondition (GATE 1, RATS "Appraisal Policy for Evidence") - a violation makes the attestation invalid, independent of any consumer. The one trust-relative step - the covering signatures verify against a consumer-named substrate key - is a per-row **evidence tier** `attested` / `unattested` / `declared` (GATE 2, RATS "Appraisal Policy for Attestation Results"); a consumer with no pinned substrate root treats every substrate row as `unattested`, and an `unattested` substrate row ranks with `artifact` (rank, never relabel). Caught intercepted rows are covered by `interception` records, reconstructed rows by `examination` records, and clean intercepted rows by BOTH a run-level `arming` record and a `sealed` record. The observation vocabulary now travels on-wire (`observationVocabulary`: labels, caught subset, JCS digest), so the recompute and GATE 1 are pure functions of carried bytes. Renamed `interceptRecords` -> `observationRecords`, `interceptRefs` -> `observationRefs`, and `does_not_assert` -> `doesNotAssert` (old spellings rejected, no alias). Record signatures are DSSE PAE over `(payloadType, payload)`; `payloadType` is now a producer-defined `+json` media type (Probity emits `application/vnd.probity.aee-observation.v1+json`). `batchRoot` is pinned to RFC 6962 with domain separation, **duplicate records rejected**, and the leaf is now over the record's PAE bytes; it is required whenever records exist. A new `runEntropy` digest folds a substrate-emitted run-start value into a versioned run binding, anti-splice rather than a freshness challenge. `issuedAt` is now REQUIRED. Removed the v0.3 payload chain, `expectedPayloadSha256`, `dstIp`/`dstPort`/`wasTls`, and the orphan-hmac degraded-record concept; attribution strength is non-normative producer nuance. Stated the field-partition (delta-I) and the named non-claims (run-population completeness out-of-wire; no cross-predicate composition). - **v0.5** - Split the per-row `basis` into two orthogonal REQUIRED fields: `basis` (closed vocabulary `substrate` / `artifact`) names the vantage, and the new `method` (closed vocabulary `intercepted` / `reconstructed`) names the directness, both by weakest-input composition. The 0.4 values `substrate_observed` / `artifact_reported` / `inferred` are rejected, not aliased (`inferred` had no successor because it conflated the two axes). Made `actualLayer` REQUIRED on every row (a missing member is malformed, a different altitude than the fail-closed row members) and extended its literal `none` to caught rows (observed-but-not-enforced). Added the consumer strength orderings (`basis` bounds a `fail`, `method` bounds a `pass`, fail-closed rows at the lattice bottom) and stated the row-travel design invariant and the `basis`/`method` producer-claim trust boundary. - **v0.4** - Added a required per-row `basis` field (closed vocabulary `substrate_observed` / `artifact_reported` / `inferred`, fail-closed on unknown) so each observation carries its own vantage; pinned the `actualLayer` clean-run behavior to the literal `none` rather than omitting the field; and renamed `does_not_assert` to `doesNotAssert` with no alias. - **2026-07-14** - v0.3. Breaking change to the intercept-record shape (verify-then-read). `interceptRecords[]` entries became standard DSSE envelopes `{ payload, payloadType, signatures[] }` carrying the EXACT signed bytes, replacing the flat readable-field object; the readable fields moved INSIDE the signed payload and are read only AFTER the per-record signature verifies over `PAE(payloadType, b64decode(payload))`. `batchRoot` became a SINGLE predicate-level RFC-6962 field. Added `does_not_assert[]` and documented the orphan-hmac degraded state. (All superseded by v0.6.) - **2026-07-08** - v0.2. Breaking restoration of the run-integrity anchors the v0.1 redesign dropped: the corpus `manifest.classes` became a `class -> attackId[]` map with coverage checked at ATTACK granularity; a run-identity binding on every intercept record (closes cross-run transplant); `interceptRefs` as an unforgeable foreign key; removal of the redundant `contained` boolean with `actualLayer <-> caught` coherence enforced; the composite payload chain; out-of-vocabulary `containmentObserved` fail-closed; CANON-EQ (signed payload must be its own JCS canonical form); and `expectedCorpusDigest` + `expectedSubstrateDigest` as REQUIRED offline-verifier inputs. - **2026-07-06** - Initial v0.1. The greenfield evidence-first replacement for the retired `scorecard-verdict` predicate: fused recomputable `result` (replacing `verdict` + `verdict_reason`); `observationEnvironment` grouping the substrate / corpus / catchPolicy / networkPosture bindings; the digest-committed corpus `manifest` replacing a producer-asserted denominator; `attackResults` (was `cve_results`); `interceptRecords` (was `forensic_traces`); lowerCamelCase field names. ============================================================================== # DOCUMENT: v1/adversarial-execution-evidence-consumer-policy # CANONICAL: https://getprobity.dev/predicate/v1/adversarial-execution-evidence-consumer-policy # MARKDOWN: https://getprobity.dev/predicate/v1/adversarial-execution-evidence-consumer-policy.md ============================================================================== # Adversarial Execution Evidence — Consumer Policy Profile, v0.1 > ## THIS DOCUMENT IS A PROFILE, NOT THE SPECIFICATION > > **The authoritative predicate text is `spec/predicates/adversarial-execution-evidence.md` in > the `in-toto/attestation` fork**, mirrored byte-identically in the `aee-conformance` > repository. That document is 2,322 lines, whose sha256 is > `759d2383e5da36fa509dc335e6159a20b87641b25ebbadcf1676c55d75ffd8b0`; this one is 798. This > document does not restate the predicate. It states the **consumer-policy half** the > predicate deliberately assigns outside itself, and most of what is written here is text the > authority does not carry. The ledger below says which sentences those are, one row each. > > **Do not read a sentence here as one the specification imposes.** The pin ledger > [`adversarial-execution-evidence-consumer-policy.pins.json`](adversarial-execution-evidence-consumer-policy.pins.json) > records, per sentence, which of the two it is. A sentence recorded as `anchored` is carried > verbatim by the authority at a named span. A sentence recorded as unanchored under class > `profile-addition` is **new normative text proposed for the next revision of the predicate** > and has no standing beyond this profile until that revision lands. A sentence under > `prose-divergence` restates an authority obligation in different words, and only a person > reading both texts establishes that the two say the same thing. > > **Why this exists.** The predicate splits verification in two. A byte-pure validity core > every consumer evaluates identically, and everything irreducibly trust-relative, which it > assigns to consumer policy. As of 2026-07-30 the trust-relative half existed only as one > vendor's undocumented policy across three enforcement rails, and an audit of that policy > found ten consumer controls of which two are derivable from the published predicate, the > absent-pin behaviour of those two is not, the highest-value control is not reachable from > the published text at all, and the one obligation the predicate makes mandatory is missing > from the implementation. Four independent implementers reading the predicate today would > build four different consumers, and none of them would build ours. > > This file carries 38 sentences imposing `MUST` and 0 imposing `REQUIRED`. Every normative > sentence below is enumerated there: 8 are anchored to a span of the authority that carries > that sentence verbatim, and 30 are recorded as unanchored. The counts are over sentences, > taken with this banner and the fenced examples excluded and wrapped paragraphs rejoined > first. > > **What the pin proves, and its honest limit.** That the authority has not moved since a > person last read it against this text, and that every predicate member this profile > constrains exists in the authority at all. It does not prove the authority imposes any > obligation here, and for most of them it does not. **Profiles:** `https://in-toto.io/attestation/adversarial-execution-evidence/v0.7` **Status:** draft for the standards thread. Nothing here is adopted upstream. --- ## 0. Scope, and what this document is not ### 0.1 The split this profile occupies The predicate states the boundary it draws, and the boundary is the reason this document exists rather than a longer specification: > The comparison is deliberately not a validity gate: validity is a function of carried > bytes alone and holds identically for every consumer, while the expected corpus and > substrate differ per consumer. A verifier proceeds in two stages. Stage one is byte-pure and belongs to the predicate: statement well-formedness, coverage validity, the `result` recompute, and manifest and vocabulary digest integrity. Stage two is trust-relative: the envelope signature, the per-row evidence tier against consumer key policy, then the strength orderings and the rest of consumer policy. **This profile is stage two, and only stage two.** The membership test for this document is mechanical, and it is not "does the predicate currently say so". It is: **does the rule read anything the consumer supplies?** A rule with no consumer operand — no pin, no key, no clock, no threshold — is byte-pure and belongs in the predicate's validity gate however recently it was written, however unimplemented it is upstream, and however convenient it would be to ship it here first. ### 0.2 Three rules deliberately moved OUT of this profile Three obligations were carried as one vendor's consumer policy at the time this profile was written. All three are byte-pure by the test above, and publishing them here would announce as trust-relative three rules that hold identically for every consumer — precisely the confusion this document exists to end. Each belongs in the predicate's coverage-validity list, and each is filed there rather than here: - **A clean row may not resolve an `observationRefs` index to an `interception` record.** Shipped today as an unconditional admission rule with no consumer knob gating it. It is a pure predicate over `containmentObserved`, `observationRefs`, and the decoded kind of a carried record. - **Every carried `interception` record is resolved by at least one caught row.** Unwritten anywhere. Same shape, same argument. Its value against an attacker who deletes the records rather than orphaning them is zero, and it must not be priced as a closure. - **`subject` cardinality.** The predicate already makes this a validity rule — "For this predicate `subject` MUST contain exactly one entry on a statement of any basis; a statement carrying zero or more than one subject is malformed, regardless of whether any row is `basis: substrate`" — and the reference rego rail enforces it nowhere while two corpus vectors sit on a denylist under two mutually contradictory reasons for why it is out of scope. It is trivially expressible and it is not consumer policy. A reader looking for those three in this document will not find them, and that absence is deliberate rather than an omission. ### 0.3 Conformance language `MUST`, `MUST NOT`, `MAY` and `SHOULD` carry their RFC 2119 senses. They bind a **consumer**: a party that reads an adversarial-execution-evidence statement and makes a decision from it. They bind no producer and constrain no wire format. A **conforming consumer at a stated level** satisfies every obligation that level names, and declares — in the machine-readable rail map of section 6 — the disposition of every obligation it does not enforce. **A consumer that silently omits an obligation is non-conformant even where the omission is forced by its engine**, because the omission and the declaration are what a relying party can tell apart. --- ## 1. The obligation registry Fourteen obligations. Each carries a permanent slug of the form `aee-p-`. The slug is never renumbered, never reused, and retired only by a tombstone row. **Why a slug and not an ordinal.** Every ordinal in the reference implementation that names an obligation has drifted, and the drift reached the diagnostics: a filter step renumbers Kyverno condition indices so that conditions 13 through 16 are reported as 11 through 14 in every corpus-run message; a prose reference to "conditions (6) and (7)" as separately requiring `basis` and `method` names a single combined entry and a length check; a policy header says fifteen conditions where the file carries sixteen. Spec line anchors are no better for this purpose — an obligation outlives any line of a document under active review, and the anchor gate that exists in the sibling repository exists because those lines move. A slug cannot drift, because there is nothing to recompute. | Slug | Obligation, in short | Source | Consumer input | Absent-input default | |---|---|---|---|---| | `aee-p-tier-derivation` | derive a per-row evidence tier before crediting any `basis: substrate` row | authority L537-542 | substrate observation keys | mandatory; no opt-out | | `aee-p-no-tofu` | with no pinned substrate root, every `basis: substrate` row is `unattested`, and the root is never inferred from the predicate | authority L542-545 | same | mandatory; no opt-out | | `aee-p-ignore-carried-tier` | ignore a carried `evidenceTier`, and any predicate-level member with the reserved prefix | authority L550-552 | none | mandatory; no opt-out | | `aee-p-corpus-anchor` | pin the expected corpus digest out of band and compare it at consumption | authority L1213-1218 | expected corpus digest | deny, on the two-leg argument | | `aee-p-substrate-anchor` | pin the expected substrate digest out of band and compare it at consumption | authority L1213-1218 | expected substrate digest | deny, on the two-leg argument | | `aee-p-threshold` | admit only the result tokens this consumer has declared | authority L448-449 | accepted result tokens | the authority's own default, the strict end | | `aee-p-clean-row-live` | key on each clean row's vantage, directness and derived tier | authority L450-453, L1270-1272 | a declining boolean | keep the obligation | | `aee-p-key-window-operand` | evaluate a key validity window against a substrate-signed instant, never the producer's clock | authority L925-943 | per-key validity window | vacuous; the obligation is conditional | | `aee-p-admission-result` | expose one admission result conjoining validity, tier policy and the anchors | authority L1223-1226 | none | recommended, not mandatory | | `aee-p-demanded-scope` | pin the assessment classes this deployment demands and require the evidence to cover them | new | demanded class codes | deny, on the third-leg argument | | `aee-p-issuance-lag` | bound the interval between the substrate-signed instant and the producer's clock | new | a bound in hours | vacuous, deliberately | | `aee-p-evidence-age` | bound the age of the evidence against the substrate-signed instant | new | a bound in hours | vacuous, deliberately | | `aee-p-posture-allowlist` | require the carried network posture to be one this deployment accepts | new, promoted from a MAY | allowed posture tokens | vacuous | | `aee-p-catchpolicy-pin` | require the carried catch-policy digest to equal a pinned one | new, promoted from a MAY | expected catch-policy digest | vacuous | The `Source` column is the justification for publishing at all, and it should be read as the answer to one question: **could an independent implementer, reading only the published predicate, arrive at this obligation?** Five of the fourteen rows say `new`, and for two more — the anchors — the obligation is reachable while its absent-input behaviour is not. Of section 2's additions only the two replay pins have any counterpart upstream, and that counterpart is a bare permission with no mechanism attached. --- ### 1.1 `aee-p-tier-derivation` — the one obligation the predicate makes mandatory The authority states it, and this profile adds nothing to it: > Given a valid attestation, a consumer MUST, before crediting any `basis: substrate` row or > applying either strength ordering, derive a per-row evidence tier: a `basis: artifact` row > is `declared`; A `basis: substrate` row is `attested` when every covering record's signature verifies against a key this consumer's policy names as a substrate observation key, and `unattested` otherwise. The tier is total and deterministic given the key policy, and it never alters `result`. **A consumer MUST NOT treat this obligation as optional on the ground that its engine cannot perform it.** An engine with no signature primitive cannot derive a tier; that fact makes the consumer's admission result mean less, and it does not make the obligation go away. Such a consumer declares `unreachable` under section 6 and states the reduced meaning of its result under section 5. **Consumer input.** A set of substrate observation keys, each an Ed25519 public key. A record's `keyid` is an unauthenticated lookup hint that selects which pinned key to try and is never the check itself. **What declining costs.** Everything the predicate's second gate is for. Every field a keyless consumer reads out of a record payload — `aeeRunBinding`, `aeeMethod`, the record kind — comes out of a base64 payload whose signature nothing in that consumer's path checks, so anything a real substrate can write into a record, the party assembling the statement can write too. ### 1.2 `aee-p-no-tofu` — no trust on first use > A consumer with no policy-pinned substrate root MUST treat every `basis: substrate` row as > `unattested` and MUST NOT infer the substrate root from the predicate. This is the obligation that makes `aee-p-tier-derivation` total rather than conditional: there is no state in which a consumer has no key policy and therefore no tier. It has one. Every substrate row derives `unattested`, and a consumer whose admission rule requires `attested` admits no substrate-bearing statement at all until it pins a key. **A consumer MUST NOT resolve a record's `keyid`, or any other carried value, into a trust anchor it did not already hold.** The predicate carries the substrate's own subject reference in `observationEnvironment.substrate`, and reading that as an instruction about which key to trust is exactly the inference this obligation forbids. ### 1.3 `aee-p-ignore-carried-tier` — the reserved prefix > A carried predicate member named `evidenceTier`, or any predicate-level member beginning > with the reserved prefix `aee`, MUST be ignored and MUST NOT alter the derivation. A consumer satisfies this by never reading such a member. Satisfying it by accident — because no rule happens to name one — is satisfaction, but it is not a declaration, and section 6 requires the disposition to be stated either way. The distinction matters the first time a producer ships a carried tier and a consumer that never considered the question starts reading it because the field is conveniently there. ### 1.4 `aee-p-corpus-anchor` and `aee-p-substrate-anchor` The authority makes both mandatory in one sentence, and the sentence is unambiguous about the members compared: > A consumer MUST pin, out of band, the corpus digest and the substrate digest it expects for > the deployment it is admitting into, and at consumption MUST compare them against > `observationEnvironment.corpus.digest` and `observationEnvironment.substrate.digest`; on > mismatch the attestation is not admitted, exactly as an attestation whose covering > signatures do not verify is not admitted. These are two obligations rather than one because a consumer can hold one value and not the other, and a rail that folded them together would let an operator who pinned the corpus believe it had pinned the substrate. **Why in-policy re-derivation cannot substitute, and this is the part an implementer gets wrong.** Every other corpus check available to a consumer is self-consistency: the digest commits the manifest, the coverage parts partition it, the attack identifiers exhaust it. Self consistency costs an attacker holding only the envelope key nothing. Substituting a weaker corpus for the real one — one declaring a single trivial attack the run duly passes — and re-hashing the substitute needs no key at all, and the substituted statement then satisfies every recompute a consumer can run. **The difference between the real corpus and the substitute is not carried in the bytes, so no amount of in-policy re-derivation can reach it.** Only a value pinned outside the attestation can. **A consumer MUST NOT derive a pinned anchor value from a bundle the party being checked supplied.** A digest copied out of a producer's own statement pins whatever the producer chose to ship, and it will match forever. The absent-input default is argued in section 2.1, and it is the single most important sentence in this profile that the published predicate does not contain. ### 1.5 `aee-p-threshold` — the admission threshold, and why it is not the row gate > The default admission threshold is `result == "pass"`. A consumer MAY accept `pass_indirect` beside `pass`. **A consumer's accepted-result set MUST be either `{pass}` or `{pass, pass_indirect}` and MUST NOT be any other set.** This is a threshold over the ordering `fail < degraded < pass_indirect < pass`, not a free choice of tokens: a set naming `pass_indirect` without `pass` names no threshold, the empty set names none, and a set naming `degraded` admits a disclosed coverage gap. Each is a typo rather than a policy, and **a consumer MUST refuse a malformed accepted-result set by name rather than falling back to a default**, because a fallback here silently narrows or widens a running deployment. **The absent-input default is the authority's own**, `{pass}`, and this is the one place in the profile where an absent input neither denies nor is vacuous. The corpus, substrate and scope pins deny when absent because the obligation exists whether or not the consumer states it and only the consumer holds the value. The replay pins and the freshness bounds are vacuous when absent because nothing obliges a consumer to set them at all. Here the predicate itself supplies the value an absent pin would otherwise have to demand, and that value is the strict end of the range, so absence cannot admit anything a pin would have refused. ### 1.6 `aee-p-clean-row-live` — the row obligation a relaxed threshold must keep > A consumer MAY accept `pass_indirect`, and a consumer relaxing its threshold below `pass` > MUST additionally key on each clean row's `basis` and `method` and on that row's derived > evidence tier, because below `pass` the ordinal stops distinguishing them: a `degraded` > reached through a disclosed coverage gap and a `degraded` whose clean rows are all > `artifact` carry the same token. and, at the authority's own consumer-policy example: > a policy relaxed to admit `pass_indirect` MUST keep the rule, because the token states that > some clean row is indirect and never which one. The obligation itself: a clean row — one whose `containmentObserved` is inside the carried `observationVocabulary.labels` and outside its caught subset — is `basis: substrate` and `method: intercepted`. A clean row that is artifact-basis or reconstructed is a "nothing bad happened" claim assembled from state diffing rather than from a live observation at the substrate. **Three normative consequences, and the first two are where the reference implementation was wrong until 2026-07-30.** **A consumer MUST NOT relax `aee-p-threshold` to admit `pass_indirect` and decline `aee-p-clean-row-live` in the same act.** These are two declarations, and a policy in which one flag performs both is non-conformant however its two halves are individually defensible. Under such a flag no configuration admits `pass_indirect` with the row rule still running, so the conformant posture is not merely easy to miss, it is inexpressible. **A consumer that cannot derive an evidence tier MUST NOT relax `aee-p-threshold` at all.** The authority's sentence requires a relaxing consumer to key on the derived tier as well as on `basis` and `method`, so the relaxation is unavailable to a consumer that declares `aee-p-tier-derivation` unreachable. Separating the threshold from the row gate satisfies the `basis`-and-`method` half of that sentence and leaves this half open; a keyless rail offering the relaxation is non-conformant on this row even after that separation. **A consumer declining `aee-p-clean-row-live` under a `{pass}`-only threshold MUST be refused rather than accepted as a no-op.** A statement carrying an indirect clean row recomputes to `pass_indirect` and is refused by the threshold before the row gate is reached, so that pairing changes nothing, and an operator who wrote it wanted something it does not do. **Polarity.** The authority's non-normative example spells this knob as an opt-in to strictness whose default is lax. **This profile inverts it: the obligation holds by default and a consumer declines it explicitly.** An implementer following the example literally builds a less safe consumer than an implementer following this profile, and that divergence is stated here rather than left to be discovered. ### 1.7 `aee-p-key-window-operand` — the operand, where a consumer bounds a key The obligation is conditional on the consumer choosing to bound a key's validity, and where it applies the authority fixes the operand hard: > Where it does, that window MUST be evaluated against a substrate-signed instant, and the one > this predicate mandates is the `armedAt` carried inside an `arming` record whose signature > verifies under the key being bounded; it MUST NOT be evaluated against `issuedAt`. `issuedAt` is producer-asserted, sits outside every substrate signature, and is not among the run binding digest's inputs, so a party holding the envelope key moves it at will, changing no digest and breaking no signature. A window evaluated against it rehabilitates, by back-dating alone, every record the revoked key ever signed. **A consumer bounding a key's validity MUST refuse a statement carrying no `arming` record that verifies under the bounded key, and MUST NOT fall back to `issuedAt`.** Such a statement carries no substrate-signed instant for that key and therefore supports no claim about its own age. Reading absence as satisfaction is the failure this obligation exists to correct. The predicate amended itself for this operand once already, and the changelog states why in a sentence this profile takes as its own standard of evidence: one implementer choosing the defeated operand is a mistake, and two choosing it separately is a property of how the field reads. ### 1.8 `aee-p-admission-result` — one result, not two > Verification surfaces SHOULD expose one consumer-facing admission result that conjoins > validity, tier-policy satisfaction, and the anchor comparison, so a result-only consumer > cannot read a valid-but-wrong-context attestation as admissible. This profile keeps it a SHOULD and widens what the conjunction covers: an admission result exposed under this profile conjoins validity, every obligation the consumer's declared level names, and every obligation it declares `enforced` above that level. **A surface exposing an admission result MUST NOT report as admitted a statement failing any obligation it declares `enforced`.** --- ## 2. The obligations this profile adds Four obligations and one absent-input rule that the published predicate does not carry. This section has no counterpart upstream and is the profile's clearest single contribution. ### 2.1 The absent-input default for the two anchors: DENY The predicate makes the anchor comparison mandatory and says nothing whatever about a consumer that has not pinned. **A consumer that has pinned neither the expected corpus digest nor the expected substrate digest MUST refuse the statement, and MUST NOT treat the absent pin as vacuously satisfied.** Two legs carry this, and both are needed. **The appeal-to-the-predicate leg.** A policy that treats an absent mandate as vacuously satisfied has implemented a recommendation. The two replay pins in section 2.4 default permissive precisely because nothing in the predicate obliges a consumer to set either; this pair is different in kind. **The asymmetric-failure leg.** The two failure modes are not symmetric. Defaulting permissive fails silently and indefinitely: an operator who believes they pinned, or who never read this far, admits evidence about any corpus and nothing in the verdict ever says so. Defaulting strict fails loudly and once, at rollout, with an error naming the exact input that is missing. **The cost, stated rather than hidden.** An unedited copy of a policy written to this profile admits nothing until the operator pins. Rolling out through an audit-first variant measures the blast radius before it blocks. **Declining this obligation MUST be an explicit act and MUST NOT be reachable by leaving an input unset.** ### 2.2 `aee-p-demanded-scope` — the demanded assessment classes **A consumer MUST pin the set of assessment class codes its deployment demands, and MUST refuse a statement whose `coverage.assessedClasses` does not contain every demanded code.** Absent that pin the consumer refuses, on the argument below. **Classes, not attack identifiers, and an implementer asks this first.** Attack identifiers are corpus-version-scoped, so a consumer pinning them re-pins on every corpus revision, and the list it would re-pin to is one it read out of the producer's own manifest rather than one it chose. **A pin whose value comes from the party being checked is not a demand.** **This is not the corpus anchor restated.** The corpus anchor pins bytes and says nothing about what those bytes must contain; a consumer that pinned a digest it copied out of a producer's bundle has pinned whatever the producer chose to ship. **This is the only pin in the profile whose value a consumer derives from what it wants** rather than from what it was handed. **The absent-input default is deny, and the appeal-to-the-predicate leg does not carry it.** The demanded-scope obligation is new text in this profile, so a consumer unpinned here would not be implementing a recommendation in place of a mandate. A third leg replaces that one and is stronger: an unpinned corpus anchor still leaves the consumer every self-consistency rule available to it, a weakened control; **an unpinned scope demand leaves it nothing, because the withdrawal is provably invisible in the bytes.** Absence here is not a degraded control, it is the absence of one, and that is the case for making the operator say so. **The opt-out is its own input.** The two decline different things: declining the anchors concedes "evidence about any corpus and any substrate", declining the scope demand concedes "a producer may withdraw any class it likes". **A consumer MUST NOT fold the two declinations into one input**, because an operator who declined the digest pin would then have declined the scope demand without ever saying so. **An empty pin is not a pin.** An empty array, or a value that is not an array, comprehends to the empty set. **A consumer MUST treat an empty demanded-class set as an absent pin** and refuse under the rule above, naming the input. **Its honest limit travels with it.** This obligation converts an undetectable attack into a documented consumer obligation that some consumers will not perform. That is a real reduction in assurance relative to a validity rule, and it is the best available, because the validity rule does not exist: coverage suppression is unreachable by any rule over the carried statement, measured byte-identical on 27 of 27 finding-bearing baselines against honest analogues, three rails agreeing. And the obligation does not reach the form in which a corpus manifest never declared the demanded class in the first place, which is the one form of scope withdrawal that reaches a passing verdict. ### 2.3 `aee-p-issuance-lag` and `aee-p-evidence-age` — the two freshness bounds Neither bound exists in the published predicate. What the predicate does fix, normatively, is the operand both must use, and section 1.7 states it. **A consumer bounding the interval between the substrate-signed instant and the producer's clock MUST evaluate it as `issuedAt` minus the `armedAt` of every carried `arming` record, and MUST refuse the statement if any one of them exceeds the bound.** Every arming record, not the earliest or the latest: a run whose vantages were armed hours apart is a run whose weakest record is the one that matters. **A consumer bounding the age of the evidence MUST evaluate it as its own clock minus `armedAt`, and MUST NOT evaluate it against `issuedAt`.** **The absent-input default is vacuous, and deliberately not the deny that the anchors and the scope demand take.** Those implement obligations that exist whether or not a consumer states them. There is no such obligation here: the predicate does not require a consumer to bound the age of its evidence at all, and the obligation it does state is conditional — that a consumer which bounds age evaluates the bound against a substrate-signed instant. **Denying an unpinned consumer would be inventing a requirement rather than enforcing one.** **The applicability rule is not vacuous, and it is where an implementation goes wrong.** A consumer that has pinned a bound and is handed a statement carrying no arming record **MUST deny**, and MUST NOT pass it and MUST NOT fall back to `issuedAt`. ### 2.4 `aee-p-posture-allowlist` and `aee-p-catchpolicy-pin` — the two replay pins The predicate grants these as a bare MAY with no mechanism: > Consumers MAY additionally coherence-check row claims against the pinned > `observationEnvironment`: a `substrate` row claiming a network-boundary observation under a > `networkPosture` that provides no interception path at that boundary is incoherent, and a > consumer MAY reject on that ground. This profile gives the permission a shape. A consumer MAY pin the set of `networkPosture.posture` tokens it accepts and the expected `catchPolicy` digest; where it pins either, **a consumer MUST refuse a statement whose carried value is outside the pinned set or unequal to the pinned digest.** Both are vacuous when absent, on the argument that nothing obliges a consumer to set either. **A consumer MUST NOT evaluate either pin inside its validity gate.** These are private consumer values, and a rail that lets one of them decide a validity rule makes a specification-valid statement structurally unsound on that rail alone. Where such a rail is also the oracle for a conformance corpus, it hollows the corpus in the direction that only bites the consumers who followed the guidance. The reference rego rail does exactly this today, and appendix B records it. --- ## 3. Profile levels A level is a named set of obligations, so that a consumer states what it meets instead of publishing a bespoke list nobody can compare. | Level | Obligations | |---|---| | **Minimal** | `aee-p-tier-derivation`, `aee-p-no-tofu`, `aee-p-ignore-carried-tier`, `aee-p-corpus-anchor`, `aee-p-substrate-anchor`, `aee-p-threshold` | | **Recommended** | Minimal, plus `aee-p-clean-row-live`, `aee-p-demanded-scope`, `aee-p-admission-result` | | **Strict** | Recommended, plus `aee-p-key-window-operand`, `aee-p-issuance-lag`, `aee-p-evidence-age`, `aee-p-posture-allowlist`, `aee-p-catchpolicy-pin` | **A consumer MUST NOT claim a level while declaring any obligation of that level `unreachable` or `approximated`.** A rail that cannot derive a tier meets no level of this profile, and it says so under section 5 rather than claiming Minimal with a footnote. The levels exist so that a partial consumer has something honest to state, not so that it has something flattering to claim. --- ## 4. What a byte-pure consumer cannot know This section is carried forward from the reference bundle's own account, unsoftened, because it is the most useful paragraph in it and a published profile that omitted it would be selling a gate rather than describing one. **A verifier that evaluates coverage validity without verifying the observation records' signatures cannot distinguish a substrate observation from an assembly-plane forgery.** Every record field such a consumer reads comes out of a base64 payload whose signature nothing in its path checks. Anything a real substrate can write into a record, the party assembling the statement can write too. **Stated plainly: a consumer relying on a byte-pure admission controller is extending full trust to the envelope signer.** The envelope key is the entirety of the trust decision there. The record-level cryptography is evidence for an offline auditor; it is not evidence such a gate can weigh. Three byte-pure checks narrow that gap without closing it, and none of the three is a substitute for `aee-p-tier-derivation`: - **Record signature presence.** A band-aid, not verification, and its home is the predicate's validity gate rather than this profile. It needs no key, so it holds in every deployment, but the bytes are never inspected and an attacker who writes garbage into a signature passes exactly as a real substrate does. What it closes is the literal strip: the batch-root leaves span only the payload type and payload, so deleting every signature entry leaves the root unchanged and the statement otherwise intact. - **`aee-p-clean-row-live`.** The method-and-vantage half of the authority's own example. The tier half is not computable without a key, which is why section 1.6 forbids a keyless rail from relaxing its threshold. - **Coverage validity.** The predicate's own class-match family, and a validity rule rather than a consumer threshold. The authority is explicit about its reach: a verifier that evaluates coverage validity and skips signature verification has checked that the producer filled the form in correctly. --- ## 5. Declaring a capability gap An engine without a signature primitive cannot derive an evidence tier. That is a fact about the engine, and this profile's position is that the fact is publishable rather than embarrassing — but only as a declaration. **A consumer that cannot satisfy an obligation MUST declare it `unreachable` with the engine obstruction stated, and MUST NOT omit it.** The declaration lives in the rail map of section 6. Concretely, a rail declaring `aee-p-tier-derivation` unreachable is stating all of: - its admission result binds to validity plus whatever obligations it does enforce, and **MUST NOT** be published as, or conjoined into, a claim that any `basis: substrate` row is attested; - every field it reads out of a record payload is unverified, and the envelope key is the entirety of its trust decision; - it meets no level of this profile, per section 3; - it does not offer the `pass_indirect` relaxation, per section 1.6. **A consumer MAY satisfy an obligation out of band.** Three shapes are conformant, and all three are declarations rather than omissions: 1. **An out-of-band verifier.** A tier-capable verifier runs ahead of the policy engine and the engine consumes its admission result. The engine declares `aee-p-tier-derivation` enforced by reference and names the verifier and the key policy it ran under. 2. **A sidecar annotation.** The tier column is computed elsewhere and attached to the object under decision. **A consumer relying on a sidecar MUST establish the sidecar's integrity by a mechanism outside the attestation**, because a tier annotation the same party can write is the carried `evidenceTier` that section 1.3 forbids reading, wearing a different hat. 3. **An explicit declaration that no tier is derived**, with the reduced meaning of the admission result stated above. --- ## 6. Obligation identifiers and the rail map Each rail — this project's and anyone else's — publishes a rail map declaring, for **every** slug in the registry, exactly one disposition: - **`enforced`**, with the rule, condition or code path that carries it; - **`approximated`**, with the approximation stated in both directions, so that a rail applying an obligation more widely than the profile scopes it declares a strengthening rather than leaving an undocumented divergence; - **`unreachable`**, with the engine obstruction stated. **A rail map MUST carry an entry for every registry slug, and MUST NOT carry an entry for a slug the registry does not define.** A gate asserts the correspondence in both directions. The value of the both-directions form is measured rather than assumed: the reference bundle's own cross-rail driver already refuses both an undeclared disagreement and a stale declaration whose rail has since gained the power, and the second half is the half that rots. The mapping runs one way only. **Rails map into the slug, and a rail MUST NOT define a slug.** An identifier a rail can mint is an identifier the registry does not control, and the ten engine-capability reasons the reference driver carries per vector are exactly the material that belongs on a per-obligation row instead — lifted out of a bilateral exception table, where they say "my engine agrees with one vendor's module modulo my own exception list", and put where they say what an engine can and cannot do. Registry row fields: the slug; the profile section that defines it, named rather than numbered by line; the obligation sentence; the consumer input and its type; the absent-input default and which of the four absent-input arguments carries it — the authority's own default at section 1.5, the two-leg deny at 2.1, the third-leg deny at 2.2, or deliberate vacuity at 2.3; the declining input, or none; and the denial code emitted on violation, compared as a set. Four defaults with four different arguments, and a profile that stated a default without stating which argument carried it would have published a preference. --- ## 7. The reference consumer and the conformance suite ### 7.1 The reference consumer `probity-verify`, in `packages/verify`, is this profile's reference consumer. It is the only implementation in the reference tree that derives an evidence tier, and it draws the valid-versus-admitted line this profile turns on: in bare replay mode exit 0 means the statement is valid; when any consumer policy is supplied the exit code binds to admitted, and never to bare validity. It is a reference consumer and not a conforming one, and the difference is stated here rather than in a footnote. Measured at `probity` HEAD `c2ebf615`, it enforces `aee-p-tier-derivation`, `aee-p-no-tofu`, `aee-p-ignore-carried-tier` and `aee-p-admission-result`, and it implements the anchor comparison for both anchors while **defaulting permissive when an anchor is not supplied** — an unsupplied anchor compares nothing and the statement can still be admitted. That is the opposite of section 2.1, in the file whose own type documentation states that a consumer must supply both anchors for admission decisions. The obligation is written down beside the code that does not enforce it. Appendix B carries it as a defect. The reference rego rail is the mirror image: default-deny on both anchors, and no tier at all. **Neither reference implementation is a conforming consumer, and they fail on different obligations.** That is the sharpest available argument that this document needed to be written: the boundary is not one an implementation holds unaided, even with the specification in front of it and the other implementation in the same repository. ### 7.2 The conformance suite The suite is an extension of the published external-rail contract in `aee-conformance/packaging/run_vectors.py`, not a new harness and not a suite built on the reference rego rail. **Why not the rego rail.** Its declaration keys are corpus vector identifiers and its ten reason families are engine-capability statements about three named engines. A third party's policy language is not on that list and cannot be added to it, so a suite hosted there would require an implementer to author its own exception table by running its engine against one vendor's module and transcribing whatever disagreed. The artifact that produces is a compatibility test between two implementations, not conformance to an obligation — and every defect in appendix B would become the correct answer by accident, so an implementer who followed this profile correctly would fail. **What the existing contract already gives.** A fixed argv, a set-compared answer, and a consumer context injected by environment with a per-context expectation: each vector is run twice through the same command, once with a pinned test key policy and once with none, and the second run is what puts a rail under the no-TOFU rule. Argv is fixed by the contract, so naming a flag would dictate a spelling to every rail while naming a variable dictates only where to look. **What this profile adds to it.** One response member, one context variable, one expectation cell: - the response object gains an admission verdict and a denials array, the array compared as a set exactly as the validity codes already are; - the consumer context travels in one environment variable beside the key policy, holding a path to a **consumer context document**; - each vector gains a per-context admission expectation. The consumer context document is the profile's serialization of the inputs in section 1, and it exists so that a suite can inject a policy without dictating how a policy engine spells it. **A conforming context document MUST name its members `substrateObservationKeys`, `expectedCorpusDigest`, `expectedSubstrateDigest`, `acceptedResults`, `admitUninterceptedCleanRows`, `demandedClasses`, `maxIssuanceLagHours`, `maxEvidenceAgeHours`, `allowedNetworkPostures`, `expectedCatchPolicyDigest` and `keyValidityWindow`, and MUST NOT require a rail to use those names anywhere else.** The serialization is a wire between the suite and a rail, and a profile that let it leak into the rail's own configuration would be dictating a spelling. **Verdicts are three-valued from the first line.** `admit`, `deny`, and `did-not-run`. **A suite MUST report a rail invocation that crashed, timed out, or emitted an unparseable answer as `did-not-run`, and MUST NOT record it as a denial.** This is not a hypothetical defensiveness: the reference cross-rail driver's oracle returns a denial when its engine process fails for any reason, indistinguishable from a genuine one, and a vector the rail under test also denies produces zero disagreement and is reported as agreement. A negative is reportable only when the check demonstrably ran. **Corpus pin.** The coverage figures below were measured against the conformance corpus this repository vendors at `packages/verify/vectors/aee-v06/` — release `v0.8.0`, source commit `0c4e27ec4f28b03d9688885bb5a00c6e96334d1e`, manifest digest `eb24532be577ee6a936410e6850482847cecac07147aa3c76d737fc730d4bf2b` — which carries 272 vectors: 61 `accept`, 209 `reject`, 2 `indeterminate`. `scripts/lint_failure_codes.py` holds this declaration to the vendored copy on all three axes and refuses to grade anything when they disagree, and it counts the figures in the next paragraph out of that same corpus rather than reading them here. **The honest state of the coverage this suite starts from.** **No expectation in it is an admission expectation.** The manifest's expectation shape has no member naming an admission outcome at all, which is the structural reason nothing this profile adds is measurable from the corpus yet, and it is the same absence that leaves the four anchor codes of section 1.4 with zero forcing vectors — a zero recomputed from the manifest on every run rather than asserted here. 6 vectors carry a per-context evidence-tier expectation, `tierWithPinnedKey` beside `tierWithoutKey`, and in 4 of them the two contexts differ. **That four, not the six, is the forcing coverage of section 1.1's mandatory obligation.** A vector whose two contexts agree is satisfied by a consumer that never consults its key policy at all, so it cannot distinguish a consumer that derives the tier from one that does not; only a vector that answers `attested` under a pinned key and `unattested` without it can. Four out of 272, and none at all for section 1.4: the mechanism the suite needs is proven and its coverage is a token. The one carrying condition `aee-c-33` — that the evidence tier is derived per row and never carried — is the only vector in the corpus declaring it, and it is the one that exercises the obligation across rows of mixed basis. **The profile's first release ships the vectors, and until it does no claim of conformance to this profile is measurable.** --- ## 8. Non-claims, and what this profile does not cover ### 8.1 What a conforming consumer has and has not established A consumer at Strict has established that a valid statement about the corpus and substrate it pinned, covering the classes it demanded, carries a result at or above its threshold, with every substrate row attested under keys it pinned, inside the freshness bounds it set. It has not established that the run happened. The substrate is a passive sensor with no view of the runner that injects the corpus, so the set of attacks actually executed is a producer assertion, and no consumer obligation in this profile reaches it. It has not established that no attack was quietly withdrawn from scope. `aee-p-demanded-scope` reaches a withdrawal that leaves the demanded class out of `coverage.assessedClasses`; it does not reach a corpus whose manifest never declared the class, and that is the form that reaches a passing verdict. It has not established that a caught row was not relabelled clean. The citing form of that rewrite — a relabelled row still pointing at the `interception` record its caught predecessor cited — is closed by a validity rule this profile deliberately does not carry (section 0.2). The deleting form is open, and it is open against every rail today. ### 8.2 What this document deliberately does not cover - **Any byte-pure rule.** Statement well-formedness, coverage validity, the `result` recompute, digest integrity, and the three rules named in section 0.2. All of them belong to the predicate and none of them is trust-relative. - **The envelope trust decision.** Which key signs the outer envelope, how it is distributed, rotated and revoked, and what a consumer does on revocation. That is the attestation framework's, and for a byte-pure consumer it is the entirety of the trust decision — which makes its absence here a gap in the reader's coverage, not a claim that it does not matter. - **Substrate observation key distribution.** This profile requires a consumer to hold pinned keys and says nothing about how it gets them. - **Tier refinements.** The predicate permits a consumer policy to subdivide `attested` into stricter refinements — a hardware-attested observation key, agreement of multiple keys — and this profile standardizes none of them. A refinement refines and never reorders the three tiers, and a consumer using one declares it. - **Producing evidence.** Nothing here constrains a producer. - **Any predicate other than adversarial-execution-evidence.** A result-bearing sibling predicate carried in the same bundle is out of scope, and a rail evaluating both states which obligations it applies to which. - **Additional levels.** Three are defined. A deployment needing a fourth publishes a rail map, not a level. - **The conformance vectors themselves.** Section 7.2 designs the suite; it does not ship it. ### 8.3 An obligation not published here is not a conformance requirement Two obligations were considered for this release and are not in it, and they are named so that a reader can tell a deliberate exclusion from an oversight. A consumer requirement that every row declare a hash-pinned rather than time-window attribution has no member to read until the predicate revision that introduces one, and stating it now would be a requirement about a field that does not exist. A requirement that a consumer bound the number of records a statement may carry has no argument behind it yet. **This profile makes no claim to completeness.** It covers the consumer surface of one shipped implementation, audited against one specification revision, and an obligation nobody has written down cannot be one this profile enforces. A document that could not fail would be worth nothing, and enumerating what it does not reach is what keeps it able to. --- ## Appendix A — the reference rails The rego module in `deploy/admission/rego/probity_admission.rego` is **an** implementation at a stated set of dispositions, and it is not the definition of this profile. Two facts about it must travel with any conformance claim made on it. **The cross-rail conformance driver does not execute the deployment artifact the bundle recommends.** It loads the three Kyverno documents and one CUE file; the deep policy-controller policy that embeds the full module — the one the install instructions recommend — is kept in sync by a generator and a byte-equality check, which is not an execution. **A correctly pinned deployment fails that driver by construction.** The driver asserts that each policy carries exactly two unreplaced anchor placeholders, so an operator who follows the documented first install step and replaces both turns their own policy files red. The check is correct as a repository drift guard and fatal to any "run this against your own deployment" reading of it. ## Appendix B — known defects in the reference implementations Recorded so that a reader who goes to the source is not misled. Every one is either fixed or disclosed before a conformance claim cites these rails. 1. **The rego rail derives no evidence tier**, and credits `basis: substrate` rows without one. It is a non-conformant consumer of its own predicate, and until 2026-07-30 neither the module nor its documentation said so — the capability gap was stated honestly and its conformance consequence was not. 2. **The rego rail offers the `pass_indirect` relaxation while unable to derive a tier**, which section 1.6 forbids. The 2026-07-30 separation of the threshold from the row gate closed the `basis`-and-`method` half of that sentence and left this half open. 3. **`probity-verify` defaults permissive on both anchors**, against section 2.1, in a file whose own documentation states the obligation it does not enforce. 4. **Two consumer pins are evaluated inside the rego rail's validity gate.** The catch-policy and posture pins are conjuncts of the module's soundness rule rather than of its admission rule. Latent today because both default permissive, and live for exactly the consumers who follow the guidance and pin them. 5. **`subject` cardinality is enforced nowhere in the rego rail**, and two corpus vectors are denylisted under two reasons that are each false and that contradict each other. 6. **The cross-rail driver's oracle is two-valued**, recording a crashed or unparseable engine invocation as a denial. 7. **The reference bundle's prose carries stale figures throughout** — vector counts, test counts, rail-comparison tables, condition ordinals, and a worked command whose sample statement predates a run-binding version change and now fails. No figure in it should be quoted, and this profile quotes none. ============================================================================== # DOCUMENT: v1/security-verdict # CANONICAL: https://getprobity.dev/predicate/v1/security-verdict # MARKDOWN: https://getprobity.dev/predicate/v1/security-verdict.md ============================================================================== # Probity Security-Verdict, v1 **predicateType:** `https://getprobity.dev/predicate/v1/security-verdict` A security-verdict is a signed, offline-verifiable statement that a single named artifact was run in isolation, attacked with a pinned corpus of exploits, and given a PASS or FAIL verdict with per-attack evidence. It is the product output of an adversarial scan: an admission controller, a CI gate, or an auditor reads it to decide whether to admit the artifact, without re-running the scan and without trusting the tool that produced it. This document specifies the predicate body. The signing envelope, subject binding, and offline-verify mechanics are in [signing-envelope.md](signing-envelope.md). The forensic evidence the verdict references is specified in [catch-record.md](catch-record.md). 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). ## Subject The subject is the **scanned artifact, by OCI digest**. This is the deployable image the verdict is about, so an admission controller or registry can link the verdict to the exact image it is about to admit. ```json "subject": [ { "name": "", "digest": { "sha256": "<64-hex-oci-digest>" } } ] ``` ## Predicate ```json { "verdict": "fail", "verdict_reason": "rule cve:CVE-2025-53109 (CRITICAL) bypassed expected layer 'substrate.vfs_hook'", "session_id": "", "corpus_digest": "", "gate_engine_commit": "", "substrate_ref": { "name": "", "digest": { "sha256": "<64-hex>" } }, "policy": { "id": "", "blocking_severities": ["CRITICAL", "HIGH"] }, "attack_results": [ { "rule_id": "cve:CVE-2025-53109", "severity": "CRITICAL", "expected_layer": "substrate.vfs_hook", "blocked": false, "actual_layer": null, "full_payload_sha256": "<64-hex>" } ], "evidence_ref": { "batch_root": "<64-hex-rfc6962-batch-root>", "bundle_id": "<64-hex-cas-bundle-id>" }, "execution_envelope": { "trace_id": "" }, "compromised_state_ciphertext_digest": "" } ``` ## Field semantics - **`verdict`** (REQUIRED) - `pass` or `fail` (lowercase). Determined by the `policy`, not by a naive "every attack blocked" rule. - **`verdict_reason`** (REQUIRED) - a human-readable summary of why the verdict is what it is. On a fail it names the first blocking-tier bypass; on a pass it states that all blocking-tier classes were contained. This is a display string; automated consumers use the structured fields. - **`session_id`** (REQUIRED) - the session id from the producer's signed session manifest, which binds together the session id, substrate digest, corpus digest, and engine identity at the moment the corpus was injected. It binds the verdict to a specific injection event: a verifier with the manifest can confirm the corpus was injected into the named substrate for this exact run, rather than stitched together afterward. The manifest proves INJECTION; `corpus_digest` alone proves only the corpus DEFINITION. - **`corpus_digest`** (REQUIRED) - the SHA-256 (64-char lowercase hex) of the packaged attack corpus the run was produced against. It pins the INPUT: the signed verdict commits to a specific set of exploit payloads, so a third party need not trust that the producer threw the *named* exploits rather than something else. **Honesty:** `corpus_digest` is NOT a solo completeness guarantee. It proves *which* corpus package was used; it does not, by itself, prove the corpus was *exhaustive* or that its payloads are genuinely adversarial. Whether the corpus is the complete approved set is verified out-of-band against an auditor-supplied anchor (see Value-binding anchors). On its own, `corpus_digest` is a pin on the input identity, not a proof of input quality or coverage. - **`gate_engine_commit`** (REQUIRED) - an **auditor-pinnable identity of the engine** that produced the verdict, in the spirit of an SLSA `builder.id`. Its purpose is identity-binding, not source reproduction: an auditor who knows the approved engine identity can confirm the verdict was produced by that engine, and the value also participates in the signed session manifest binding. **Honesty:** a third party cannot, from this string alone, re-derive or re-run the engine; the field lets an auditor *pin and compare* an identity it already knows, not independently reconstruct the producer. - **`substrate_ref`** (REQUIRED) - a reference (name + digest) to the isolation substrate the scan ran on. In this standard the substrate is an opaque, separately attested input identified only by its digest; how the substrate's integrity is attested is a wave-2 concern (see [README.md](README.md)). A verifier treats `substrate_ref.digest` as the value to check against an auditor-supplied approved digest and against a revocation log (below). - **`policy`** (REQUIRED) - the severity-tier ruleset. `id` names the ruleset (versioned independently of the predicate so the ruleset can evolve without bumping the predicate). `blocking_severities` lists the severity tiers whose unblocked attack forces a `fail`. - **`attack_results`** (REQUIRED) - one entry per attack in the corpus. (This generalizes an earlier CVE-only shape: an attack is no longer assumed to be a CVE.) Each entry: - **`rule_id`** (REQUIRED) - a namespaced rule identifier. The namespace before the colon names the rule family; the remainder is the family-specific id. CVE is ONE namespace among others: `cve:CVE-2025-53109`. Other rule families use their own namespace prefixes. A bare, un-namespaced id is non-conforming. - **`severity`** (REQUIRED) - the severity tier of the rule. - **`expected_layer`** (REQUIRED) - the [registry](vocabulary-registry.md) `layer` value expected to block this attack. - **`blocked`** (REQUIRED) - `true` iff a catch-record was emitted whose normalized layer equals `expected_layer` (and whose violation type matches). Decided over the normalized [vocabulary registry](vocabulary-registry.md) values. - **`actual_layer`** (REQUIRED) - the `layer` that actually fired, or `null` if none did. - **`full_payload_sha256`** (OPTIONAL) - the SHA-256 of the exact payload thrown for this attack, using the canonical key `full_payload_sha256` (matching the catch-record key). Pins the per-attack input down to the byte. - **`evidence_ref`** (REQUIRED) - a REFERENCE to the forensic evidence, not the evidence itself. The verdict does NOT embed catch-records inline (which would make the envelope unbounded). Instead it carries: - **`batch_root`** (REQUIRED) - the RFC 6962-style batch Merkle root over the run's ordered catch-records (see [catch-record.md](catch-record.md)). - **`bundle_id`** (REQUIRED) - the content-addressed SHA-256 of the verdict bundle, the key under which the catch-records are resolvable from content-addressed storage. A verifier resolves the catch-records from content-addressed storage by `bundle_id`, recomputes the batch root, and confirms it equals `batch_root` before trusting any `blocked: true`. The records are the evidence behind every block; the reference keeps the verdict bounded while keeping the evidence cryptographically tied to it. - **`execution_envelope`** (OPTIONAL) - application-level correlation metadata for the run (for example an opaque trace id). It carries no security claim; it is a convenience pointer for correlating the verdict with other observability data. - **`compromised_state_ciphertext_digest`** (OPTIONAL, top-level) - present ONLY on a `fail` verdict that captured a forensic snapshot of the compromised state. It is the SHA-256 of that snapshot, encrypted to the customer's public key. The producer proves the snapshot exists and is bound to the verdict; only the customer can decrypt the snapshot itself. **Honesty:** a verifier confirms a ciphertext digest is committed in the signed verdict, not that the plaintext is a faithful capture of the compromised state (the verifier cannot decrypt it). ## Verdict rule (severity-tiered) 1. For each attack: `blocked = true` iff there exists a catch-record (in the referenced batch) whose normalized `layer` equals the attack's `expected_layer` and whose `violation_type` matches. 2. `verdict = fail` iff there exists an attack with `severity` in `policy.blocking_severities` and `blocked == false`; otherwise `verdict = pass`. 3. `verdict_reason` names the first blocking-tier bypass on a fail, or states that all blocking-tier classes were contained on a pass. Per-attack results are ALWAYS recorded regardless of verdict. A miss in a non-blocking tier is recorded as evidence, not as a fail. ## Verification (third-party, offline) After the envelope checks in [signing-envelope.md](signing-envelope.md) succeed (signature, `keyid`, `predicateType`, subject non-empty): 1. Confirm the `subject` digest equals the artifact the verifier intends to admit. 2. Validate every `attack_results[].rule_id` is namespaced, and every `expected_layer` / `actual_layer` (when non-null) is a value in the [vocabulary registry](vocabulary-registry.md). 3. Resolve the catch-records by `evidence_ref.bundle_id`, verify them per [catch-record.md](catch-record.md), recompute the RFC 6962 batch root, and confirm it equals `evidence_ref.batch_root`. Confirm that every `blocked: true` is backed by a matching catch-record. 4. Apply the auditor-supplied value-binding anchors (below). 5. Apply the substrate revocation check (below) before trusting a `pass`. No live execution environment is required. ### Value-binding anchors (auditor-supplied) A signature proves the bytes were not tampered with; it does not by itself stop two residual forgeries. An auditor (who alone knows the approved reference values) supplies two external anchors, checked alongside the verdict: - **Exhaustion** - the auditor supplies the COMPLETE `rule_id` set of the approved corpus. The verdict's `attack_results` MUST cover exactly that set. A verdict that silently omits attacks (a short `attack_results`) is rejected, defeating a skip-the-hard-attacks forgery. This is the out-of-band check that backs the honesty caveat on `corpus_digest`: exhaustion is established against the auditor's anchor, not asserted by the verdict alone. - **Substrate freshness** - the auditor supplies the approved substrate digest. `substrate_ref.digest` MUST equal it. A valid signature over a real-but-stale substrate is rejected. These anchors are independently verifiable, but only relative to values the auditor brings; a verifier with no auditor anchors can still check the signature, the subject binding, and the evidence, but cannot establish exhaustion or freshness from the verdict alone. ### Substrate revocation (OpenVEX) A cryptographic signature never expires, but the substrate a verdict attests can be retroactively broken: a vulnerability disclosed AFTER the verdict was signed can turn a once-valid `pass` into a stale one. Before trusting a `pass`, a verifier MUST consult an [OpenVEX](https://github.com/openvex/spec) revocation log for the `substrate_ref` digest. If the log carries an `affected` or `under_investigation` statement against that digest, the verifier MUST treat the `pass` as revoked regardless of signature validity. This closes the time-of-check / time-of-use gap on the substrate. In v1 the revocation log MAY be a single static published document the verifier fetches and checks against the `substrate_ref` digest. An automated feed that opens a VEX statement when a substrate-relevant vulnerability is disclosed is roadmap, not v1. ## Related standards - **[SLSA Verification Summary Attestation (VSA)](https://slsa.dev/spec/v1.0/verification_summary)** - a security-verdict plays a role analogous to a VSA (a signed statement that a verification ran and what it concluded). `gate_engine_commit` is modeled on the VSA `builder.id` idea: an auditor-pinnable identity for the producer of the verdict. A security-verdict diverges from a VSA in carrying adversarial per-attack results and a forensic-evidence reference rather than a policy-evaluation summary. A consumer that wants a VSA-shaped summary can derive one from a security-verdict. - **[OpenVEX](https://github.com/openvex/spec)** - used for substrate revocation, as above. - **[in-toto](https://github.com/in-toto/attestation) / [DSSE](https://github.com/secure-systems-lab/dsse)** - the envelope; see [signing-envelope.md](signing-envelope.md). - **[catch-record](catch-record.md)** - the referenced forensic evidence. ## Versioning This is the security-verdict v1 predicate. Adding a new optional field is additive and stays within v1; a verifier ignores fields it does not recognize. A change to the verdict rule, a rename or removal of a field, or a change to the `attack_results` shape is breaking and requires a new major version. The `policy` ruleset is versioned independently via `policy.id`. ============================================================================== # DOCUMENT: v1/static-scan-verdict # CANONICAL: https://getprobity.dev/predicate/v1/static-scan-verdict # MARKDOWN: https://getprobity.dev/predicate/v1/static-scan-verdict.md ============================================================================== # Predicate: `static-scan-verdict` (v1) ``` https://getprobity.dev/predicate/v1/static-scan-verdict ``` The **Tier-1 detection verdict**: the outcome of a STATIC scan of an MCP server / A2A agent — the scanner connects over the transport and analyses the *declared surface* (protocol/schema conformance, tool descriptions, annotations, known-bad static patterns) **without booting a microVM and without executing the server's tools**. It is the cheap, CI-safe, pre-merge rung of the containment ladder (the Tier-2 [adversarial-execution-evidence](adversarial-execution-evidence.md) attestation runs the thing in the box; this one never does). ## Why it is a distinct type `adversarial-execution-evidence` (Tier 2) REQUIRES runtime-only bindings a static scan cannot honestly fill — the `observationEnvironment` substrate / catchPolicy / networkPosture bindings, the runtime corpus manifest, `attackResults`, and the drained `interceptRecords`. A pure static scan boots nothing and drains nothing, so reusing `adversarial-execution-evidence` would force fabricated/empty substrate bindings — a verifier could not tell a real static scan from a defenses-off runtime gate. `static-scan-verdict` carries only the fields a static scan genuinely produces. ## Predicate body | Field | Req | Meaning | |-------|-----|---------| | `verdict` | REQUIRED | `PASS` / `DEGRADED` / `FAIL`. DEGRADED when a layer was `NOT_RUNNABLE`. | | `tier` | REQUIRED | always `"static"` — the containment-ladder rung this verdict was earned at. | | `scan_engine` | REQUIRED | the static scanner identity (e.g. `mcp-test-toolkit`). | | `coverage_qualifier` | REQUIRED | plain-English scope, e.g. "static surface only — no microVM, no runtime execution". | | `layers` | REQUIRED | array of the static pyramid layers, each `{id, status, is_scorable, detail, findings[], metrics}`; a `finding` is `{check_id, severity, message}`. | | `coverage_map` | REQUIRED | `{static, runtime, hosting}` booleans — which ladder rungs this verdict covers (`static:true`, the rest `false`). | | `corpus_digest` | OPTIONAL | SHA-256 of the static payload corpus the scan used (informational; pins the static check set). | | `scorecard_ref` | OPTIONAL | a reference to the full Tier-2 runtime evidence attestation. | A producer MAY add fields additively within v1; a verifier MUST ignore unknown fields. ## Binding contract `static-scan-verdict` is **verdict-bearing** (it is in the admission-eligible predicate-name set) but carries **NO catch-policy binding** — there is no substrate catch-policy in a static scan. It is therefore in the verdict-bearing registry but NOT in the catch-policy-bound subset (the verdict subset that MUST bind a 64-hex catch-policy digest). The verifier accepts a `static-scan-verdict` whose DSSE signature verifies and whose `predicateType` is registered; it does NOT require any runtime binding. The optional additive `egress_posture` field, if present, MUST be a known posture (fail-closed), exactly as the Tier-2 evidence predicate's `networkPosture.posture`. ## Honesty scope Tier 1 is the BUILT, most-mature pillar (the `mcp-test-toolkit` static pyramid). A `static-scan-verdict` says only what was visible **without executing the server** — it never claims a runtime catch. The cross-tier story (a payload that PASSES static here and FAILS runtime in the Tier-2 gate) is exactly why Tier 2 exists; this verdict is honest about being the static half. ## Related - [`adversarial-execution-evidence`](adversarial-execution-evidence.md) — the Tier-2 runtime evidence attestation (the box). - [`compliance-crosswalk`](compliance-crosswalk.md) — how a signed verdict maps to control catalogs. - [`README`](README.md) — the predicate standard index + the predicate-name registry. ============================================================================== # DOCUMENT: v1/scorecard-verdict # CANONICAL: https://getprobity.dev/predicate/v1/scorecard-verdict # MARKDOWN: https://getprobity.dev/predicate/v1/scorecard-verdict.md ============================================================================== # Probity Scorecard-Verdict, v1 **predicateType:** `https://getprobity.dev/predicate/v1/scorecard-verdict` A scorecard-verdict is a signed, offline-verifiable, **multi-layer** assessment record about one system under test (SUT) - an MCP server or an A2A agent. It carries a tri-state outcome (`pass` / `degraded` / `fail`), the per-layer results the outcome was folded from, and an explicit coverage statement naming what was *not* assessed, so a clean outcome can never be read as a claim about ground the assessment never touched. This type is unusual in this standard and the reader is owed the reason up front: **two independent producers mint statements under it, and their predicate bodies do not share a single field.** They are specified here as two disjoint **profiles** with a normative discriminator. A consumer that dispatches only on `predicateType` and then assumes one shape will mis-read the other; this document exists so that does not happen. This document specifies the predicate body. The signing envelope, subject binding, and offline-verify mechanics are in [signing-envelope.md](signing-envelope.md). 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). ## Status The two profiles are at different points in their lives, and a holder needs to know which one they are holding before anything else. - **Profile A (`assessment`) is live.** The reference implementation (`mcp-test-toolkit-pro`, `scorecard/_probity.py`) emits it today. New statements of this type are minted in Profile A. - **Profile B (`substrate-gate`) is superseded and MUST NOT be newly issued.** Its producer lineage was renamed to [adversarial-execution-evidence](adversarial-execution-evidence.md), which carries its own in-toto-namespace type URI and is the type a substrate-gate run emits now. Profile B is specified here because statements in that shape exist and a recipient who dereferences this URL to read one is entitled to an answer. A producer that would emit Profile B MUST emit `adversarial-execution-evidence` instead. - **The Probity reference verifiers reject this type.** `@probity/verify` rejects it by name as retired, `website/public/verify/probity-verify.py` does not carry it in its known-type set, and no key in the published `.well-known/verification-keys.json` registry lists it under `authorized_predicates`. That is deliberate and it is not a defect in this specification: it means the Probity default trust anchor does not authorize any key to sign this type, so a verifier that intends to accept a scorecard-verdict MUST be configured with a trust anchor of its own that authorizes the signing key for it (see [signing-envelope.md](signing-envelope.md), Trust root - a verifier MUST accept a configurable anchor and MUST NOT hardcode one). Absent such configuration, fail-closed rejection is the correct behaviour and a consumer MUST NOT treat rejection as a transport error. ## Profile discriminator (normative) A verifier MUST select the profile before reading any other field, using the presence of a single key. The two markers are REQUIRED in their own profile and never emitted by the other, so the test is total and unambiguous. | Marker present in `predicate` | Profile | Read | |---|---|---| | `ar4si_tier` | **A** - `assessment` | [Profile A](#profile-a---assessment) | | `cve_results` | **B** - `substrate-gate` | [Profile B](#profile-b---substrate-gate) | | neither, or both | (undefined) | REJECT fail-closed | A verifier MUST reject a statement carrying neither marker or both. There is no default profile and no heuristic fallback: a body that does not identify itself is not a scorecard-verdict this specification defines. --- ## Profile A - `assessment` The outcome of running a layered assessment arsenal against one target. Four layers are defined (`compliance`, `security`, `eval`, `load`); each reports how it ran, what its own gate said, its raw metrics, and any sub-check it could not soundly evaluate. The overall verdict is a lattice join over the per-layer results, and every input to that join is carried under the signature, so the join is recomputable offline. ### Subject The subject is the **assessment record itself, by digest** - not the scanned artifact. The producer receives a typed verdict aggregate rather than the target's raw declared surface, so it binds the subject to the exact assessment it is attesting: a one-byte change to any layer outcome, metric, finding, gap, or coverage figure changes the subject digest. ```json "subject": [ { "name": "", "digest": { "sha256": "<64-hex assessment digest>" } } ] ``` `subject[0].name` is the assessed target's identifier and is load-bearing: it is an input to the subject digest (see [Verification](#verification-third-party-offline), step A2) and is the only place the target is named. There is exactly one subject entry. Consumers should note what this subject is **not**. It does not bind an image digest, so a Profile A statement on its own does not tie the assessment to a particular build of the target. A consumer needing that binding MUST obtain it from another attestation. ### Predicate ```json { "verdict": "pass", "verdict_rendered": "PASS (partial coverage 2/6)", "ar4si_tier": "affirming", "layers": [ { "kind": "security", "status": "ran_clean", "tier": "static", "verdict": "clean", "is_scorable": true, "metrics": { "findings": 0, "native_checks": 4 }, "findings": [], "gaps": [], "immunities": [], "detail": null } ], "structural_coverage": { "covered_classes": ["AS", "CO"], "total_classes": 6, "routed_elsewhere": { "HOTF": "load" }, "out_of_scope": {}, "is_partial": true }, "effective_coverage": { "covered_classes": ["AS", "CO"], "total_classes": 6, "routed_elsewhere": { "HOTF": "load" }, "out_of_scope": {}, "is_partial": true }, "assurance_gaps": [], "immunities": [], "assurance_context": { "aamp_blind_spots": [], "cascade_quiescence": {}, "is_empty": true } } ``` All nine fields are REQUIRED and always present; the producer emits the object literally, with empty arrays and empty objects rather than omissions. There is no optional field in Profile A. ### Field semantics - **`verdict`** (REQUIRED, string) - one of `pass`, `degraded`, `fail`, lowercase. The lattice join over `layers[].verdict`, defined in [Verdict rule](#verdict-rule-profile-a) below. It is recomputable from the predicate. - **`verdict_rendered`** (REQUIRED, string) - the coverage-qualified display string. It is `verdict` upper-cased, except that a `pass` at partial *effective* coverage renders `PASS (partial coverage N/M)` where `N` is `len(effective_coverage.covered_classes)` and `M` is `effective_coverage.total_classes`. A `degraded` or `fail` renders bare; their non-green status already signals that they are not a clean pass, and a coverage qualifier on them would be redundant. It is recomputable from the predicate and carries no information the structured fields do not; a consumer SHOULD derive its own display string and MAY use this one only to confirm the producer's own rendering agrees. - **`ar4si_tier`** (REQUIRED, string) - the verdict rendered in the IETF RATS AR4SI trustworthiness-tier vocabulary, so a relying party reads standards-aligned semantics rather than a vendor word. The mapping is total and recomputable: `pass` -> `affirming`, `degraded` -> `warning`, `fail` -> `contraindicated`. The fourth AR4SI tier, `none` ("no claim made"), is reserved for an absent verdict and is never emitted here, because this predicate always carries one. A clean result is therefore an explicit affirmation, never an absence of failure. This field is also the Profile A discriminator (see above). - **`layers`** (REQUIRED, array) - one object per assessed layer. A layer that did not run is present with a non-`ran_*` status rather than omitted; a layer absent from the array was not attempted at all, which the reference producer avoids. Each object: | Field | Req | Type | Meaning | |---|---|---|---| | `kind` | REQUIRED | string | which layer: `compliance`, `security`, `eval`, `load` | | `status` | REQUIRED | string | HOW it ran: `ran_clean`, `ran_findings`, `not_installed`, `not_runnable`, `errored` | | `tier` | REQUIRED | string | the scorability tier it was exercised at: `static`, `runtime`, `observed_trace` | | `verdict` | REQUIRED | string | what its gate SAID: `clean`, `blocked`, `errored`, `not_scorable`, `gaps_present` | | `is_scorable` | REQUIRED | boolean | `true` iff `status` is `ran_clean` or `ran_findings` | | `metrics` | REQUIRED | object | raw per-layer counters, values numeric. Free-form and extensible; no composite or normalized score is emitted anywhere in this predicate | | `findings` | REQUIRED | array | possibly empty; each `{check_id, severity, message}`, all strings, `severity` one of `CRITICAL`, `HIGH`, `MEDIUM`, `LOW`, `INFO` | | `gaps` | REQUIRED | array | this layer's assurance gaps; shape below | | `immunities` | REQUIRED | array | this layer's proven-absent sub-checks; shape below | | `detail` | REQUIRED | string or null | human-readable note, e.g. the `not_runnable` reason or an `errored` summary; explicit `null` when absent | `status` and `verdict` are deliberately orthogonal: the first says how far the layer got, the second says what its gate concluded. A verifier MUST NOT infer either from the other beyond the `is_scorable` equivalence stated above. - **`structural_coverage`** and **`effective_coverage`** (both REQUIRED, object) - the coverage statement, in two forms. `structural_coverage` is the target-independent arsenal map: which attack classes the producer's check set covers at all. `effective_coverage` is that map net of the classes whose every mapped check turned out to be unavailable *on this target*. Both share one shape: | Field | Req | Type | Meaning | |---|---|---|---| | `covered_classes` | REQUIRED | array of strings | the class codes covered, by any layer | | `total_classes` | REQUIRED | integer | the denominator: the full class count in scope | | `routed_elsewhere` | REQUIRED | object | class code -> the layer that covers it instead of a dedicated detector. These classes ARE covered; the map records the mechanism, not an exclusion | | `out_of_scope` | REQUIRED | object | class code -> the reason it is not covered by any layer. Honest disclosure of a gap, not a failure | | `is_partial` | REQUIRED | boolean | `true` iff `len(covered_classes) < total_classes` | Class codes are short opaque identifiers whose meaning is defined by the producer's published taxonomy; this specification does not enumerate them and a verifier MUST NOT assume a fixed set. `effective_coverage` is materialized at construction, not computed at verification time, so the signature covers a snapshot. A verifier can check its internal consistency (`is_partial` against the two counts) but cannot re-derive which classes were dropped without the producer's check-to-class mapping, which is not carried here. - **`assurance_gaps`** (REQUIRED, array) - the aggregate manifest of every sub-check or check-set across all layers that RAN but could not be soundly evaluated on this target. This is the mechanism that stops an un-evaluable check from reading as a passing one: a non-empty manifest drives its layer's `verdict` to `gaps_present`, which caps the overall verdict at `degraded`. Two variants, discriminated by `kind`: | `kind` | Fields | |---|---| | `per_check` | `check_id` (string), `a2a_class` (string or null), `basis` (string), `gap_kind` (string), `reason` (string), `evidence` (object, string-valued, possibly empty) | | `coverage_set` | `check_set` (string), `a2a_class` (string or null), `n_skipped` (integer), `reason` (string) | All listed fields are REQUIRED within their variant. `basis` is the admissibility class of the evidence that WAS available, and is why the check could not anchor - one of `self`, `sign`, `wit`, `rec`, `att`, `proof`, in ascending strength. A gap at `basis: "self"` means the only evidence available was the target's own unattested report. `gap_kind` is a short machine-readable tag from a closed set; the values the reference producer emits are `insufficient_hops`, `unstamped_hops`, `no_root_context_id`, `no_canary_stream`, `no_streamed_artifacts`, `tampered_sequence`, `missing_causal_parent`, `missing_delegated_subgraph`, `non_streaming_executor`, `temporal_indeterminate`, `laundering_indeterminate`, `unanchored`, `not_observed`, `runtime_flow_unscorable`. The `coverage_set` variant exists so a large external check-set that did not run is one row with a count rather than N rows. - **`immunities`** (REQUIRED, array) - the aggregate manifest of sub-checks that did not need to run because the surface they target is provably absent. Each entry is `{check_id, a2a_class, basis, reason, evidence}` with the same types as the `per_check` gap variant. An immunity does NOT cap the verdict - a provably-absent surface is covered, not unassessed - which makes it the one construct here that can keep a `pass` clean while a check did not run. It is therefore gated: the reference producer grants an immunity only at `basis: "proof"`, its strongest admissibility class. A verifier that does not accept that gate MUST treat a non-empty `immunities` array as it would a gap and cap the verdict itself; the array is carried explicitly so that decision is available to the consumer rather than absorbed silently by the producer. - **`assurance_context`** (REQUIRED, object) - the meta-assurance block, and it is about the *producer*, not the target. It records what the producer knows about its own calibration: `aamp_blind_spots` (array of strings; blind-spot classes an adaptive adversary surfaced against the check arsenal itself) and `cascade_quiescence` (object, integer-valued; the quiescence oracle's run summary), plus `is_empty` (boolean, `true` iff both are empty, meaning neither meta-assurance probe ran in this invocation). It is segregated into its own block precisely so it can never fold into the target's verdict or surface as a finding against the target. A consumer MUST NOT read a blind spot here as a finding about the assessed target. ### Verdict rule (Profile A) `verdict` is a fold over `layers[].verdict`, in this order: 1. If `layers` is empty -> `degraded`. An assessment that scored no layer is not a pass. 2. Else if any layer's `verdict` is `blocked` or `errored` -> `fail`. Worst-wins; a `fail` is never downgraded by a later layer. `errored` is fail-closed on purpose: the producer tried to score the layer and could not complete, which a consumer gate must catch rather than wave through. 3. Else if any layer's `verdict` is `not_scorable` or `gaps_present` -> `degraded`. This caps a would-be pass and cannot mask a fail, because step 2 ran first. 4. Else -> `pass`. The fold is total over the five `verdict` values. A verifier MUST recompute it and MUST reject a statement whose carried `verdict` disagrees. Coverage does not participate in the fold: a `pass` at partial coverage is a real pass over the classes that were assessed, and the coverage fields (and the `verdict_rendered` qualifier) are what keep that bounded claim honest. --- ## Profile B - `substrate-gate` The fused artifact one substrate-gate run emitted per scanned SUT: a scorecard, a containment chain, and the value-bindings that tie both to the attested substrate the run happened inside. **Superseded - MUST NOT be newly issued.** Specified here so an existing statement can be read; new work belongs in [adversarial-execution-evidence](adversarial-execution-evidence.md). ### Subject The subject is the **scanned SUT image, by digest** - not the assessment record, and not the corpus (which is pinned separately in `corpus_digest`). Binding to the image lets an admission controller link the verdict to the exact image it is admitting. ```json "subject": [ { "name": "", "digest": { "sha256": "<64-hex image digest>" } } ] ``` ### Predicate Thirteen fields are REQUIRED: `verdict`, `verdict_reason`, `session_id`, `corpus_digest`, `catch_policy_digest`, `gate_engine_commit`, `coverage_qualifier`, `coverage_map`, `substrate_ref`, `layers`, `cve_results`, `forensic_traces`, `execution_envelope`. Eight are OPTIONAL and, when unbound, are omitted from the predicate entirely rather than carried as null: `issued_at`, `detection_spans`, `vmi_enrichment`, `sut_image_digest`, `dep_closure_sha256`, `sut_provenance`, `egress_posture`, `effective_network_digest`. ```json { "verdict": "pass", "verdict_reason": "all assessed classes passed", "session_id": "", "corpus_digest": "<64-hex corpus digest>", "catch_policy_digest": "<64-hex JCS digest of the catch policy>", "gate_engine_commit": "", "coverage_qualifier": { "covered": 4, "total": 4 }, "coverage_map": { "covered_classes": ["AS", "CO", "ASRF", "ATSI"], "total_classes": 4, "is_partial": false, "out_of_scope": {}, "routed_elsewhere": {} }, "substrate_ref": { "name": "", "digest": { "sha256": "<64-hex>" } }, "layers": [ { "kind": "security", "tier": "a_card_static", "status": "ran_clean", "headline_pass": true, "is_scorable": true, "detail": null, "findings": [], "metrics": { "findings": 0, "native_checks": 4 } } ], "cve_results": [ { "cve_id": "EGRESS-EXFIL-everything", "severity": "CRITICAL", "expected_layer": "policy.egress_sinkhole", "contained": true, "actual_layer": "policy.egress_sinkhole", "containment_observed": "egress_captured", "dst_ip": "203.0.113.7", "dst_port": 443, "was_tls": true } ], "forensic_traces": [ { "timestamp": "2026-06-23T16:08:06.380567191Z", "file": "sinkhole.go", "function": "Capture", "line": 42, "layer": "policy.egress_sinkhole", "violation_type": "EGRESS_SINKHOLE_CAPTURE" } ], "execution_envelope": { "otel_trace_id": "<32-hex W3C trace id>", "mode": "live" }, "issued_at": "2026-06-23T16:08:07Z", "detection_spans": [ { "exploit_id": "EGRESS-EXFIL-everything", "dispatch_ts": "2026-06-23T16:08:05.793095985Z", "response_ts": "2026-06-23T16:08:06.380567191Z" } ], "sut_image_digest": "<64-hex - equals the subject digest>", "dep_closure_sha256": "<64-hex>", "egress_posture": "sinkhole", "effective_network_digest": "<64-hex>" } ``` ### Field semantics - **`verdict`** (REQUIRED, string) - `pass`, `degraded`, or `fail`. The Profile B verdict is **containment-axis and severity-independent**: any attributed catch (a `cve_results` row with `contained == true`) forces `fail`; no catch plus complete coverage yields `pass`, which is a coverage-bounded observation and not a guarantee of safety; an incomplete run forces `degraded`. No severity threshold participates. Per-row `severity` is triage data carried under the signature, not a verdict input, and there is deliberately no severity-ruleset field. - **`verdict_reason`** (REQUIRED, string) - a non-empty free-text reason. - **`coverage_qualifier`** (REQUIRED, object) - `{covered, total}`, both integers. `total` MUST equal `coverage_map.total_classes`. Structured rather than a rendered string, so a consumer applies its own threshold and derives its own display text. - **`coverage_map`** (REQUIRED, object) - `covered_classes` (array of strings), `total_classes` (integer), `is_partial` (boolean), `out_of_scope` (object, class -> reason), `routed_elsewhere` (object, class -> covering mechanism). All five REQUIRED. Semantics as in Profile A, except that Profile B carries one map rather than a structural/effective pair. - **`layers`** (REQUIRED, array) - per-layer results. Each object REQUIRES `kind` (string), `tier` (string), `status` (string), `headline_pass` (boolean or null - `null` when the layer did not run), `is_scorable` (boolean), `detail` (string or null), `findings` (array of `{check_id, severity, message}`), and `metrics` (object). A verifier MUST ignore unknown fields inside a layer object and unknown keys inside `metrics`; a producer MAY add both additively. Note that Profile B's layer object is NOT Profile A's: it carries `headline_pass` and no `verdict` / `gaps` / `immunities`. The two MUST NOT be parsed by one reader. - **`cve_results`** (REQUIRED, array) - the per-vector containment results. Each entry REQUIRES `cve_id` (string), `severity` (string), `expected_layer` (string), `contained` (boolean), and `actual_layer` (string or null, carried as explicit `null` when nothing fired). Optional per-row: `full_payload_sha256` (string; the pinned input hash on a `payload_bound` row), `containment_observed` (string; the producer always sets it), and the egress destination tuple `dst_ip` / `dst_port` / `was_tls`, present only on an egress-capture row and omitted otherwise. `containment_observed` is a closed enum naming the binding strength: | Value | Meaning | |---|---| | `payload_bound` | the corpus pinned `sha256(expected artifact)` and it matched a drained catch's `full_payload_sha256`. Strongest | | `window_bound` | attributed by the drain-barrier timestamp window | | `batch_bound` | caught during a campaign, not bound to a specific input | | `egress_captured` | a credential-exfil attempt captured at the sinkhole, bound by a seeded canary marker surviving in the catch's scrubbed payload | | `canary_bound` | RESERVED - names the canary rung directly; the producer records a canary hit as `egress_captured` | | `uncontained` | RESERVED - a genuine containment failure. No producer path emits it: the producer performs no escape detection independent of the substrate's own catches. Reserved so a future producer can record one without a schema break | | `not_exhibited` | the vector was dispatched and the SUT never exhibited the behaviour, so there was nothing to contain. NOT a containment failure and NOT an evasion | A catch label accompanies `contained == true`; `not_exhibited` and `uncontained` accompany `contained == false`. - **`forensic_traces`** (REQUIRED, array) - the catch records drained from the substrate. Each entry REQUIRES `timestamp` (string), `file` (string), `function` (string), `line` (integer), `layer` (string), and `violation_type` (string); a producer MAY attach further optional catch-record fields (`payload_snippet`, `full_payload_sha256`, `sink_blocked`, `guest_pid`, `dst_ip`, `dst_port`, `was_tls`). Trace objects are validate-but-preserve: unknown fields survive the signing round-trip byte-for-byte, so the item shape is deliberately open. See [catch-record.md](catch-record.md). - **`substrate_ref`** (REQUIRED, object) - `{name, digest: {sha256}}`, the substrate attestation this verdict inherits its trust from. - **`corpus_digest`** (REQUIRED, string) - SHA-256 of the packaged attack corpus, pinning the assessment source of truth. - **`catch_policy_digest`** (REQUIRED, string) - SHA-256 of the **RFC 8785 (JCS)** canonical JSON of the substrate catch policy - the document that tells the substrate what to catch. Reproduce it by PARSING the policy and re-canonicalizing under RFC 8785, never by hashing raw file bytes. It pins the defence configuration, so a permissive policy (defences effectively off) is distinguishable from an enforcing one; without it a signed `pass` is silent about whether anything was armed. - **`gate_engine_commit`** (REQUIRED, string) - the producer's commit sha. An identity to compare against a known-good value, not a means to re-derive the producer. - **`session_id`** (REQUIRED, string) - the signed session-manifest id from injection time. - **`execution_envelope`** (REQUIRED, object) - the correlation envelope. `otel_trace_id` (32-char lowercase-hex W3C trace id) is the only gated key; further correlation fields (`span_id`, `tool_call_id`, `mode`, `mode_reason`) MAY be present, so this object is validate-but-preserve. - **`issued_at`** (OPTIONAL, string) - the RFC 3339 UTC instant the producer signed the verdict, distinct from the event timestamps inside `forensic_traces` and `detection_spans`. Its purpose is consumer-side freshness gating; the threshold is the consumer's policy, not part of this standard. - **`detection_spans`** (OPTIONAL, array) - the host-observed `{exploit_id, dispatch_ts, response_ts}` windows that drove span attribution. Embedding them makes the causal attribution checkable rather than asserted: each `exploit_id` MUST match a scored `cve_results` row, `dispatch_ts` MUST NOT follow `response_ts`, and no `exploit_id` may repeat. - **`vmi_enrichment`** (OPTIONAL, object) - `snapshot_digest` (REQUIRED, string), `triggering_catch_signature` (REQUIRED, string), `model_bom` (REQUIRED, object), plus OPTIONAL `egress_owning_task` and `freeze_binding` (`{payload, signature, key_id}`). It binds a host-side memory walk of the frozen VM to the exact catch that triggered the freeze. **This section carries no field asserting causation, use, or execution.** It is a temporal co-occurrence binding - a model was resident and accessed in guest memory at the caught instant - and MUST NOT be read as proof the model caused or was used by the caught behaviour. See [ml-bom.md](ml-bom.md). - **`sut_image_digest`** / **`dep_closure_sha256`** / **`sut_provenance`** (OPTIONAL) - the build-provenance binding. When `sut_image_digest` is present it MUST equal the subject digest, and `dep_closure_sha256` is REQUIRED alongside it (the two are a pair). `sut_provenance` is the full embedded proof body; when present its `dep_closure_sha256` MUST re-derive from its `resolved_dependencies` and equal the top-level field. See [sut-provenance.md](sut-provenance.md). - **`egress_posture`** (OPTIONAL, string) - the egress posture the substrate enforced, one of `no_network`, `allowlist`, `sinkhole`, `unsafe_bypass_egress`. An unknown value MUST be rejected fail-closed. `catch_policy_digest` pins the interception rules but not the posture, so without this field a `pass` earned under uncontained egress is indistinguishable from one earned under full capture. - **`effective_network_digest`** (OPTIONAL, string) - SHA-256, under RFC 8785 (JCS), of the substrate's non-secret posture view. It deliberately excludes every secret-bearing and transport field, so it is safe to publish and a secret rotation does not change it while a posture change does. ### Legacy variance (Profile B) Profile B's wire shape was corrected on 2026-07-06, and statements produced before that date do not conform to the field list above. A reader holding one will see three differences, and this specification records them so such a file can still be read rather than mistaken for a malformed one: - `scorecard_ref` present. Its `digest.sha256` was byte-identical to `corpus_digest` by construction; it was removed as a redundant duplicate. `corpus_digest` is the single pin. - `cve_results[].blocked` in place of `contained`, and `containment_observed: "false"` in place of `not_exhibited`. The old vocabulary had inverted polarity: a vector the SUT never took the bait on was recorded `blocked: false, containment_observed: "false"`, which reads as "the attack succeeded" when it means "nothing happened". **A consumer MUST NOT read the legacy `"false"` label as a containment failure.** - `policy` (`{id, blocking_severities}`) present. It was echoed into the signed predicate but referenced by no scoring decision - the verdict is severity-independent - so a severity ruleset in the predicate was misleading and was removed. A verifier MAY refuse a legacy-shape statement outright. A verifier that accepts one MUST apply the polarity correction above. --- ## Verification (third-party, offline) After the envelope checks in [signing-envelope.md](signing-envelope.md) succeed, and after selecting the profile. **Envelope, common to both profiles.** The statement is an in-toto Statement v1 (`_type: https://in-toto.io/Statement/v1`) carried as the base64 `payload` of a DSSE envelope with `payloadType: application/vnd.in-toto+json`. The signature is raw 64-byte **ed25519** over the DSSE **PAEv1** pre-image `DSSEv1 ` computed over the *decoded* payload bytes, never over the base64 text and never over bare bytes. The `keyid` is the lowercase-hex SHA-256 of the RFC 7638 JWK thumbprint input for the raw 32-byte public key - that is, `sha256` of `{"crv":"Ed25519","kty":"OKP","x":""}` serialized with lexicographic keys, compact separators, and no whitespace. **Obtaining the public key.** The `keyid` names a key; it does not deliver one, and it is unauthenticated (it is excluded from the signed pre-image). A verifier MUST obtain the key bytes from its configured trust anchor and MUST NOT trust a key transported inside the envelope it is checking. For this type specifically: - The Probity published key registry (`.well-known/verification-keys.json`) authorizes **no** key for this predicate type, so it cannot serve as the anchor here. See [Status](#status). - The Profile A reference producer holds no key at all: signing is an injected seam and the operator supplies a raw 32-byte ed25519 seed at emit time. A Profile A statement's key therefore reaches a verifier out of band, by whatever channel the operator and the relying party agreed. A verifier MUST have that agreement in place before accepting a Profile A statement; there is no discovery mechanism for it and this specification does not invent one. **Profile A.** - **A1.** Confirm `ar4si_tier` is present and `cve_results` is not. - **A2.** Recompute the subject digest. Reconstruct the assessment record from the predicate by renaming `layers` back to `outcomes` and `assurance_context` back to `meta_assurance`, dropping the derived `ar4si_tier`, and taking `target_name` from `subject[0].name`: ``` {target_name, verdict, verdict_rendered, outcomes, structural_coverage, effective_coverage, assurance_gaps, immunities, meta_assurance} ``` Serialize that object with sorted keys, compact separators (`,` and `:`), `ensure_ascii` off, UTF-8 encoded, rejecting any non-finite number; SHA-256 the bytes. The result MUST equal `subject[0].digest.sha256`. This check is total: every input to the digest is carried in the statement, so a producer cannot alter any layer outcome, metric, finding, gap, or coverage figure without breaking it. - **A3.** Recompute `verdict` by the [verdict rule](#verdict-rule-profile-a) and confirm it matches. - **A4.** Recompute `ar4si_tier` from `verdict` and `verdict_rendered` from `verdict` plus `effective_coverage`, and confirm both match. - **A5.** Confirm each coverage map's `is_partial` equals `len(covered_classes) < total_classes`. Every key of `routed_elsewhere` SHOULD also appear in `covered_classes`, since a routed class is covered by another layer rather than excluded. - **A6.** Confirm each layer's `is_scorable` equals `status in {ran_clean, ran_findings}`, and that `detail` is present (possibly `null`). - **A7.** Confirm every `assurance_gaps` and per-layer `gaps` entry carries a `basis` from `{self, sign, wit, rec, att, proof}` and, for a `per_check` entry, a `gap_kind` the verifier recognizes; reject an unknown enum value fail-closed. Confirm that a layer with a non-empty `gaps` array carries `verdict: "gaps_present"` unless it already carries `blocked`, `errored`, or `not_scorable`, each of which takes precedence. - **A8.** Apply the consumer's own admission threshold against `verdict`, the coverage figures, and the gap and immunity manifests. A consumer that does not accept the producer's immunity gate MUST count `immunities` against coverage itself. **Profile B.** - **B1.** Confirm `cve_results` is present and `ar4si_tier` is not. - **B2.** Confirm the `subject` digest equals the image the verifier intends to admit. - **B3.** Confirm `coverage_qualifier.total == coverage_map.total_classes` and that `verdict` is one of `pass` / `degraded` / `fail`. - **B4.** For each `payload_bound` row, confirm its `full_payload_sha256` matches a `forensic_traces` entry. This is the containment chain: it is what stops a producer claiming a catch it cannot show. - **B5.** For each `detection_spans` entry, confirm its `exploit_id` matches a scored `cve_results` row and that any span-attributed catch's `forensic_traces[].timestamp` falls inside `[dispatch_ts, response_ts]`. - **B6.** If `vmi_enrichment` is present, confirm `triggering_catch_signature` appears on a `forensic_traces` entry whose `layer` is `policy.egress_sinkhole`, and if `freeze_binding` is present verify it under the pinned key and confirm its snapshot hash equals `vmi_enrichment.snapshot_digest`. - **B7.** If the provenance fields are present, confirm `sut_image_digest` equals the subject digest and that the embedded `sut_provenance.dep_closure_sha256` re-derives from `resolved_dependencies` and equals the top-level `dep_closure_sha256`. - **B8.** If `egress_posture` is present, confirm it is a known posture and reject fail-closed otherwise; if `effective_network_digest` is present, confirm it is a 64-hex SHA-256. - **B9.** Apply the consumer's own threshold against `verdict`, `coverage_qualifier`, and the per-layer `headline_pass` / `is_scorable` values. No live execution environment is required for any step above. ## What a verifier can and cannot independently check (honesty) The distinction that matters for this type is between what was **observed from outside the thing under test** and what the thing under test, or the producer, **declared**. A signature protects both equally, which is exactly why the difference has to be written down rather than left to the reader. **Measured from outside (Profile B only).** `forensic_traces` are catch records the substrate emitted at the boundary the SUT crossed; the SUT did not author them and cannot suppress them. A `payload_bound` row is bound to one of those records by a hash the producer pinned before the run. `detection_spans` are host-clock brackets around dispatch, not guest-reported timings. These are the fields a third party can chase and cross-check without trusting the producer's narrative. **Producer self-assertion (both profiles).** Everything else in `layers` - each layer's `status`, `tier`, `metrics`, `findings`, and, in Profile A, its `verdict`, `gaps`, and `immunities` - is the producer's own record of what it did, carried under the signature. A verifier confirms it was not altered after signing. It cannot confirm the layer ran, that the checks it claims to have run are the checks it did run, or that a `metrics` counter reflects real work. Profile A's subject digest binds the record to itself; it does not bind the record to reality. **Target self-report (Profile A).** `immunities` deserve separate mention because they are the one construct that keeps a `pass` clean while a check did not execute. An immunity is anchored in a declaration the assessed target made about its own surface - it says the surface is absent - which the producer then verified to its `proof` admissibility floor. The verification is the producer's, not the verifier's, and the underlying claim originates with the thing under test. A verifier that will not extend that trust MUST treat `immunities` as coverage lost, which the manifest is carried explicitly to make possible. **Coverage is a bound, not a guarantee.** A `pass` means the assessed classes were assessed and nothing blocked. It says nothing about `out_of_scope` classes, nothing about a class dropped from `effective_coverage`, and nothing about attacks outside the producer's taxonomy. `is_partial`, `out_of_scope`, `routed_elsewhere`, and the gap manifests are the contract, not a caveat on it: a consumer that reads `verdict` without reading them has read the wrong field. **Not asserted at all.** Neither profile asserts that the target is safe, that the attack corpus contained real exploits rather than no-ops (Profile B pins the corpus *digest*, never its content), that the recorded producer identity is the producer that ran, or - for `vmi_enrichment` - that a resident model caused anything. Profile A additionally asserts nothing about *which build* of the target was assessed: its subject is the assessment record, not an image. ## Related standards - **[in-toto](https://github.com/in-toto/attestation) / [DSSE](https://github.com/secure-systems-lab/dsse)** - the envelope; see [signing-envelope.md](signing-envelope.md). - **[adversarial-execution-evidence](adversarial-execution-evidence.md)** - the successor to Profile B, and the type a substrate-gate run emits now. - **[static-scan-verdict](static-scan-verdict.md)** - the static-only rung, for an assessment that boots nothing. - **[IETF RATS AR4SI](https://datatracker.ietf.org/doc/draft-ietf-rats-ar4si/)** - the source of Profile A's `ar4si_tier` trustworthiness vocabulary. - **[SARIF](https://sarifweb.azurewebsites.net/)** - per-finding `check_id` values in both profiles map onto SARIF rule identifiers; a producer MAY carry findings as, or reference, a SARIF document. This standard does not re-specify SARIF. - **[SLSA VSA](https://slsa.dev/spec/v1.0/verification_summary)** - Profile B's `gate_engine_commit` follows the VSA `builder.id` idea of an auditor-pinnable producer identity. - **[catch-record.md](catch-record.md)**, **[ml-bom.md](ml-bom.md)**, **[sut-provenance.md](sut-provenance.md)** - the component shapes Profile B references. ## Versioning This is the scorecard-verdict v1 predicate. Adding a new optional field - including additive per-layer fields, additive `metrics` keys, or an additive `cve_results` / `forensic_traces` optional - stays within v1, and a verifier MUST ignore fields it does not recognize. Adding a value to a closed enum (`containment_observed`, `egress_posture`, `basis`, `gap_kind`, a layer `status` or `verdict`) is NOT additive: enums are fail-closed at a version, so a verifier MUST reject an unknown value even while it tolerates an unknown field. A rename or removal of a field, or a change to the verdict fold, the coverage shape, or either profile's discriminator, is breaking and requires a new major version URI. The two profiles are versioned together only in the sense that they share this URI. They have no common field and no migration path between them; a producer moving off Profile B moves to [adversarial-execution-evidence](adversarial-execution-evidence.md), not to Profile A. ## Reference implementations - **Profile A** - `mcp-test-toolkit-pro`, `scorecard/_probity.py` (`scorecard_to_probity_statement`, `sign_bundle`), over the record projection in `scorecard/_sarif.py` (`scorecard_to_json`), the types in `scorecard/_model.py`, the verdict fold in `scorecard/_verdict.py` (`derive_verdict`, `render_verdict`), and the canonicalization and PAEv1 pre-image in `scorecard/_canonical.py` and `scorecard/_dsse.py`. - **Profile B** - the substrate-gate bridge in the Python gate package and its verdict-bundle signing path. That producer now emits [adversarial-execution-evidence](adversarial-execution-evidence.md); the Profile B shape here is the final scorecard-verdict wire format it carried. ============================================================================== # DOCUMENT: v2/a2a-mesh-scorecard # CANONICAL: https://getprobity.dev/predicate/v2/a2a-mesh-scorecard # MARKDOWN: https://getprobity.dev/predicate/v2/a2a-mesh-scorecard.md ============================================================================== # Probity A2A-Mesh-Scorecard,v2 **predicateType:** `https://getprobity.dev/predicate/v2/a2a-mesh-scorecard` An A2A-mesh-scorecard is a signed, offline-verifiable, **composition-safety** verdict about a mesh of agent-to-agent (A2A) microVMs. Where an [adversarial-execution-evidence](../v1/adversarial-execution-evidence.md) attestation records one SUT's bounded adversarial campaign, and a [security-verdict](../v1/security-verdict.md) reports one artifact's outcome against an attack corpus, an A2A-mesh-scorecard binds an **aggregate** subject - the whole mesh - and carries the verdict of an offline mesh evaluator over the delegation graph the mesh produced: did authority *attenuate* correctly as it flowed hop-to-hop, or did some hop broaden scope, break lineage, or escape its token? The aggregate subject is a **MeshManifest**: the N member microVMs, each bound to its frozen-VM byte-identity substrate root and its execution-grounded model bill-of-materials, plus the transport hop-observation log and the closed-world egress-policy digest. The verdict surface is the **MeshVerdict** produced by an offline evaluator that replays the hop log - never re-run at verification time, and never parsed by the transport layer that recorded it (the layering rule below). This document specifies the predicate body. The signing envelope, subject binding, and offline-verify mechanics are in [signing-envelope.md](../v1/signing-envelope.md). The member substrate roots are byteident proofs and the per-member model BOM is the [ml-bom](../v1/ml-bom.md) shape; the freeze-atomicity soundness of the underlying substrate is the [mesh-quiescence](mesh-quiescence.md) attestation. 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). ## Subject The subject is the **MeshManifest, by aggregate digest** - a single SHA-256 that commits to WHO ran (member ids), on WHAT frozen substrate (per-member substrate roots), with WHAT model weights (per-member ModelBOM digests), over WHAT delegation graph (hop log), under WHAT closed-world boundary (egress-policy digest). ```json "subject": [ { "name": "", "digest": { "sha256": "<64-hex-mesh-manifest-digest>" } } ] ``` The MeshManifest digest is computed over a **member-id-sorted** canonical projection of the manifest, so the digest is stable regardless of the order in which members were discovered. A producer MUST NOT synthesize a missing member substrate root, ModelBOM, or egress-policy digest; an empty or incomplete mesh is refused, never bound. The manifest schema is the reference implementation's `MeshManifest` (cited below); this predicate binds it by digest and projects the per-member evidence, rather than re-embedding the full BOMs. ## Predicate ```json { "member_count": 2, "bound_members": [ { "member_id": "mesh-member-a", "substrate_root": "<64-hex>", "substrate_proof_type": "fold-correctness-v1", "model_bom_digest": "<64-hex>", "model_bom_entries": 1, "model_bom_unresolved": 0 } ], "hop_count": 2, "hop_log_sha256": "<64-hex>", "egress_policy_digest": "<64-hex>", "verdict": { "scope": "ATTENUATED", "lineage": "CONTINUOUS", "reason": "all downstream hops attenuate under the root grant", "causes": [] }, "composition_safe": true, "verdict_rendered": "SAFE", "quiescence_sound": true, "quiescence_evidence_digest": null, "honest_scope": { "substrate_roots_real": false, "hop_graph_live": false, "note": "ILLUSTRATIVE SUBSTRATE: ...", "quiescence_tier": "..." }, "engine_commit": "" } ``` ## Field semantics - **`member_count`** (REQUIRED, integer) - the number of mesh members. Equals `len(bound_members)`. - **`bound_members`** (REQUIRED, array) - the per-member evidence projection, sorted by `member_id`. Each entry binds one frozen microVM's substrate and model surface so a reader can join the routing graph (the hop log's `target_agent`) to the member that ran it, and fetch the full BOM by id: - **`member_id`** (REQUIRED, string) - the member microVM's mesh identity. It matches the `target_agent` a hop records when delegating *to* this member. - **`substrate_root`** (REQUIRED, 64-hex) - the per-page Merkle root of this member's frozen-VM byte-identity capture (the byteident substrate-integrity root). Binding by root, not the whole proof, keeps the scorecard a pure subject-binding artifact; the proof is served alongside, keyed by the root. - **`substrate_proof_type`** (REQUIRED, string) - the byteident proof-family discriminator the root came from (for example `fold-correctness-v1`, `clone-uniqueness-v1`, `entropy-uniqueness-v1`), so a reader knows which proof family to fetch and verify for this member. - **`model_bom_digest`** (REQUIRED, 64-hex) - the SHA-256 of this member's canonical [ml-bom](../v1/ml-bom.md) `ModelBOM`, binding WHAT model surface the member ran without re-embedding it. - **`model_bom_entries`** (REQUIRED, integer) - the count of resolved BOM entries. - **`model_bom_unresolved`** (REQUIRED, integer) - the count of unresolved (honest-gap) BOM regions. - **`hop_count`** (REQUIRED, integer) - the number of delegation hops in the hop log. - **`hop_log_sha256`** (REQUIRED, 64-hex) - the SHA-256 of the deterministic JSONL hop-observation log. The hop log is the closed-world who-called-whom record over which the offline evaluator's verdict was computed; this digest binds the exact graph the verdict was derived from. The HopObservation record schema is specified below. - **`egress_policy_digest`** (REQUIRED, 64-hex) - the SHA-256 of the canonical egress policy in force, the closed-world boundary that makes the proxy the sole inter-agent channel. Equal to the manifest's egress digest and to the [mesh-quiescence](mesh-quiescence.md) attestation's `egress_policy_digest` for the same mesh. - **`verdict`** (REQUIRED) - the offline mesh evaluator's `MeshVerdict`. The producer carries it verbatim; it never re-runs the evaluator at verification time. Fields: - **`scope`** (REQUIRED, string) - the `AttenuationClass`: the worst downstream authority scope observed across the graph. The benign value is `ATTENUATED` (no provable escalation). Other values name a broadening relationship. - **`lineage`** (REQUIRED, string) - the `ContinuityClass`: whether the delegation lineage (context-id continuity across hops) is intact. The benign value is `CONTINUOUS`. - **`reason`** (REQUIRED, string) - a human-readable summary of the verdict. - **`causes`** (REQUIRED, array of strings) - the **trace-to-auth-downgrade invariant** cause set: the specific composition-safety violations the evaluator found. Each value MUST be from the closed `auth_downgrade_cause` vocabulary (see below); an unknown cause is rejected, not silently bound. An empty array means no composition-safety violation was found. - **`composition_safe`** (REQUIRED, boolean) - the derived safety flag: `true` iff `verdict.scope` equals `ATTENUATED` and `verdict.causes` is empty. This is the single machine-readable admission bit a consumer reads. - **`verdict_rendered`** (REQUIRED, string) - `"SAFE"` or `"UNSAFE"`, derived from `composition_safe`. A display convenience; automated consumers read `composition_safe` and the structured `verdict`. - **`quiescence_sound`** (REQUIRED, boolean) - whether the freeze-atomicity tier for this mesh's substrate was demonstrated sound (the [mesh-quiescence](mesh-quiescence.md) leg). For a synthetic fixture this is a TEMPLATE value, not a measured result; the `honest_scope` block says which. - **`quiescence_evidence_digest`** (OPTIONAL) - the SHA-256 of the companion [mesh-quiescence](mesh-quiescence.md) bundle this scorecard cross-references, or `null` when none is bound. When present, a verifier can fetch and verify that the freeze-atomicity evidence behind `quiescence_sound` exists and is itself signed. - **`honest_scope`** (REQUIRED, object) - the maturity-honesty block. Two orthogonal booleans replace the retired coarse `is_synthetic_fixture` flag, because "real jailed mesh" is two independent claims: - **`substrate_roots_real`** (REQUIRED, boolean) - `true` iff each member's substrate root was computed over a real frozen `.mem` from a jailed microVM (vs an illustrative placeholder). - **`hop_graph_live`** (REQUIRED, boolean) - `true` iff the A→B→C delegation was observed live on the wire by the host mesh-relay (vs replayed from the canonical adversarial chain). `hop_graph_live=true` with `substrate_roots_real=false` is incoherent (a live hop implies the members ran sealed). A conformant PRODUCER MUST NOT emit this pair - it has no legal state and the producer fails closed at construction; a VERIFIER, by the verifier-tolerant rule, still treats a received `(false, true)` as illustrative rather than rejecting the whole predicate. - **`note`** (REQUIRED, string) - the human-readable scope statement. For a synthetic fixture it states plainly that the per-member roots, BOM digests, and hop-graph verdict are illustrative and that this is NOT a live certification. - **`quiescence_tier`** (REQUIRED, string) - a note on what the `quiescence_sound` flag reflects and that a synthetic fixture's flag is a template value. - **`engine_commit`** (REQUIRED, string) - the auditor-pinnable engine/manifest-schema commit, in the spirit of an SLSA `builder.id`. **Honesty:** an identity to compare, not a means to re-derive the producer. - **`resident_members`** (OPTIONAL, array of strings) - the subset of member ids whose VMs were frozen WITH in-flight delegation state at the freeze instant - the memory-co-resident set the host relay reports. This is **orthogonal to `honest_scope.hop_graph_live`**: `hop_graph_live` says the delegation topology was observed on a real wire, whereas `resident_members` says which members were still memory-resident when the cohort was frozen. The two can diverge for async/streaming delegations, where an upstream member can tear down before the terminal freeze even though its hop was observed live; in a synchronous-nested run every member is resident, in an async run only a subset is. Every id MUST be a member id present in `bound_members`; a producer MUST NOT bind an id that is not in the mesh. The field is **absent** when the producer reports no resident set (an old verifier ignores it), and an empty array means no member was resident. It does NOT change the two `honest_scope` booleans. Values are sorted and deduplicated. > Forward-compatibility: a verifier MUST ignore unknown fields in the predicate and unknown keys in > `honest_scope` or in a `bound_members` entry. A producer MAY add fields additively within v2. ## `auth_downgrade_cause` vocabulary (closed) `verdict.causes` draws from the closed **trace-to-auth-downgrade invariant** cause set. As with the [vocabulary registry](../v1/vocabulary-registry.md), this enumeration is **fail-closed at a version**: a verifier MUST reject any cause not in this list. The registered causes are: ``` IDENTITY_MISMATCH TEMPORAL DOWNGRADE SCOPE_BROADENING TOKENLESS_BREAK ``` - **`IDENTITY_MISMATCH`** - a hop's verified identity does not match the identity the delegation graph requires (the callee is not who the lineage says it should be). - **`TEMPORAL`** - a hop violates a temporal ordering invariant (a delegation references a task or context that, by timestamp, could not yet exist). - **`DOWNGRADE`** - an authentication or attenuation downgrade across a hop (the invariant's namesake: authority that should only narrow instead loses a binding). - **`SCOPE_BROADENING`** - a downstream hop requests or is granted a broader scope than its caller held - the core composition-safety failure. - **`TOKENLESS_BREAK`** - a hop proceeds without the attenuation token the lineage required (an authority break with no token to bound it). The full vocabulary registration is in [vocabulary-registry.md](../v1/vocabulary-registry.md). ## HopObservation record schema The hop log committed by `hop_log_sha256` is deterministic JSONL: one HopObservation per line, sorted keys. The record is the **transport-level subset** of one observed delegation hop. The recording layer (the host mesh-router in the reference implementation) observes only what it can see at the wire chokepoint **without parsing the forwarded card-JWS or attenuation token** - it records WHO forwarded to WHOM and WHETHER a token was present by header presence only. The scope / identity / token-grant fields are emitted at their empty encodings; the offline evaluator derives those facts (Tier-1 from the callee card, Tier-2 from the token) when it replays the log. Parsing a JWS at the transport layer would be a layering violation. Per-hop record (the mesh-router's `hopWire` contract): | JSON key | Type | Semantics | |---|---|---| | `context_id` | string or null | The hop's A2A lineage anchor; `null` if none (the evaluator's lineage classifier reads a null root context as INDETERMINATE). | | `reference_task_ids` | array of strings | Prior task ids this hop references; emitted sorted for byte-stable records. Empty array (never null) when none. | | `requested_scope` | array of capability objects | The scope the hop requested. Empty at the transport layer (the router does not derive scope). Each capability: `{ "verb", "resource", "ttl_seconds" }`. | | `target_agent` | string or null | The callee this hop delegated to (the callee member's mesh id; matches `bound_members[].member_id`). Caller is positional - the chain order is the call order. | | `task_id` | string or null | The id of the task this hop produced/advanced, or null. | | `timestamp_ns` | integer or null | Wall-clock observation time in nanoseconds; `null` (unstamped) no-ops the temporal invariant. The live router stamps every hop. | | `token_bound_identity` | string or null | The identity an attenuation token bound, derived offline; null at the transport layer. | | `token_granted` | array of capability objects or null | The capabilities a token granted, derived offline; null at the transport layer. | | `token_present` | boolean | Whether an attenuation token was present on the forwarded request - by header PRESENCE only; the token bytes are forwarded untouched and never parsed by the router. | | `verified_identities` | array of strings | The identities verified for the hop, derived offline; empty array at the transport layer. | The router emits compact-separator JSON; the offline evaluator's `record_hops` produces the same key set with spaced separators. The two are SEMANTICALLY identical JSON and replay to the same verdict - the conformance property that matters is verdict-equality on replay, not byte-equality across the language boundary (cited from the mesh-router's documentation). ## Verification (third-party, offline) After the envelope checks in [signing-envelope.md](../v1/signing-envelope.md) succeed: 1. Confirm the `subject` digest equals the MeshManifest aggregate digest the verifier intends to bind to (recomputing it from the served manifest if available). 2. Validate every `verdict.causes` value against the closed `auth_downgrade_cause` vocabulary; reject any unknown cause. 3. Confirm `composition_safe == (verdict.scope == "ATTENUATED" and verdict.causes == [])` and that `verdict_rendered` agrees. 4. For each `bound_members` entry, optionally resolve `substrate_root` to its byteident proof (the `substrate_proof_type` names the family) and `model_bom_digest` to the served [ml-bom](../v1/ml-bom.md), confirming the served BOM canonical-hashes to the bound digest. 5. If `quiescence_evidence_digest` is present, resolve and verify the companion [mesh-quiescence](mesh-quiescence.md) bundle and confirm it backs the `quiescence_sound` claim. 6. Read `honest_scope.substrate_roots_real` + `hop_graph_live`: when `substrate_roots_real` is `false` the per-member roots are illustrative (not a jailed run); when `hop_graph_live` is `false` the delegation is the canonical chain, not observed live. Only `(true, true)` is a fully live jailed-mesh certification; `(true, false)` is real sealed images with replayed delegation. `(false, true)` is incoherent — treat as illustrative. 7. If `resident_members` is present, confirm every id is one of the `bound_members[].member_id`, and read it as the frozen-mid-delegation subset - the members memory-co-resident at the freeze. Do NOT conflate it with `hop_graph_live`: a member can be observed on the wire (counted toward `hop_graph_live`) yet not be in `resident_members` if it tore down before the terminal freeze. When the field is absent, the producer reported no resident set; an empty array means none were resident. No live execution environment is required, and the offline evaluator is NOT re-run; the verdict it produced is carried under the signature. ## What a verifier can and cannot independently check (honesty) - A verifier CAN confirm the scorecard is authentic, bound to its MeshManifest subject, signed by a trusted key, that the causes are all registered, and that `composition_safe` is consistent with the recorded verdict; and it can fetch and verify the per-member byteident proofs, the model BOMs, and the companion quiescence evidence to confirm the bound evidence exists. - A verifier CANNOT, from the scorecard alone, confirm that the offline evaluator *ran* the way the producer reports, nor that the hop log is the real delegation graph the mesh produced (the verdict and the hop log are producer self-assertions carried under the signature). The scorecard's guarantee is integrity, provenance, subject binding, and internal consistency - plus the ability to chase every bound digest to its served evidence - not external re-execution of the mesh evaluation. ## Why no perimeter sandbox can emit it (rationale) The aggregate subject is the standards-capture point. A perimeter sandbox can record an application's A2A calls, but it cannot bind each mesh member to a frozen-VM byte-identity substrate root (byteident is a hypervisor-boundary capture) nor to an execution-grounded [ml-bom](../v1/ml-bom.md) (the BOM's identity sources read the host's view of guest memory, not the application's API). The hop log's value is that it is recorded at the *sole inter-agent proxy chokepoint* in a closed-world mesh - a topology a perimeter sandbox does not impose. A competitor can copy the verdict shape; it cannot honestly fill `substrate_root`, `model_bom_digest`, or a closed-world hop log without the hypervisor-boundary substrate. ## Status and maturity - **Format and verification: stable, real.** The envelope, the aggregate subject binding, the `auth_downgrade_cause` vocabulary check, and the offline-verify procedure are implemented; the shipped bundle verifies under the pinned key (see the conformance vectors in `conformance.json`). - **Shipped bundle: real escalation verdict over the A->B->C fixture (illustrative substrate).** The shipped signed bundle binds the non-trivial escalating A->B->C delegation mesh: the orchestrator A holds a `read` grant, delegates to B (which attenuates faithfully), and B delegates to C which broadens to `write`. The hop graph (`hop_count == 3`) and the offline `evaluate_mesh` verdict are REAL -- the evaluator's verbatim output: `scope == "ESCALATED"`, `lineage == "CONTINUOUS"`, `causes == ["SCOPE_BROADENING"]` (the `RUNTIME-CS-DELEGATION` gate catch), so `composition_safe == false` and `verdict_rendered == "UNSAFE"`. `honest_scope` is `(substrate_roots_real=true, hop_graph_live=false)`: the three members were booted sealed in their own microVMs and frozen, so the per-member `substrate_root` values are REAL page-merkle byte-identity roots (`substrate_proof_type == "page-merkle-root-v1"`, three byte-distinct values, each cross-checked against the member's signed freeze-binding). The A→B→C delegation is still the canonical adversarial chain, not observed live. The ModelBOM is honestly empty for every member. The detection + substrate legs are real and re-verifiable; only the on-the-wire-observation leg is not yet. - **Shipped: Phase 1a (real sealed substrate roots).** `cmd/mesh-assemble` over three frozen `.mem` snapshots → real roots → `(substrate_roots_real=true, hop_graph_live=false)`. - **Open deliverable: Phase 2 (live on-the-wire delegation).** A host-side L7 mesh-relay observes a live A→B→C delegation and freezes the cohort in-flight at the escalating hop → `(true, true)`. The builder and signer consume the real manifest unchanged (the `--manifest` path; `--hops-live` flips the second boolean). See `research/62-mesh-cli-substrate-roots/PLAN.md`. - **`quiescence_sound` here is a template value** carried alongside the fixture-derived manifest, not a measured freeze result tied to this specific mesh. ## Related standards - **[adversarial-execution-evidence](../v1/adversarial-execution-evidence.md)** - the single-agent adversarial-execution attestation; this is its multi-member composition-safety analog. - **[mesh-quiescence](mesh-quiescence.md)** - the freeze-atomicity attestation behind `quiescence_sound`. - **[ml-bom](../v1/ml-bom.md)** - the per-member execution-grounded model bill-of-materials bound by digest. - **[in-toto](https://github.com/in-toto/attestation) / [DSSE](https://github.com/secure-systems-lab/dsse)** - the envelope; see [signing-envelope.md](../v1/signing-envelope.md). - **[SLSA VSA](https://slsa.dev/spec/v1.0/verification_summary)** - `engine_commit` follows the VSA `builder.id` idea. ## Versioning ### Why v2 exists v2 renames the engine-identity field to `engine_commit`. v1 spelled that field with a vendor-specific name, which a vendor-neutral standard other implementers are expected to emit must not require. Renaming a REQUIRED key is breaking under the rule stated below, so it takes a new predicateType URI rather than a silent in-place edit: one type URI never means two shapes. There is no alias and no dual-read compatibility path, and v1 is withdrawn - a verifier reads `engine_commit` or it rejects the statement. ### Rule This is the a2a-mesh-scorecard v2 predicate. Adding a new optional field is additive and stays within v2; a verifier ignores fields it does not recognize. The OPTIONAL `resident_members` field was added additively (it does not change `predicateType`, the subject digest, or any required field). A rename or removal of a field, a change to the `verdict` shape, a change to the `composition_safe` derivation, or a change to the HopObservation record key set is breaking and requires a new major version. The `auth_downgrade_cause` vocabulary follows the [vocabulary registry](../v1/vocabulary-registry.md)'s own versioning (an added cause is a registry revision; a renamed or removed cause is breaking). ============================================================================== # DOCUMENT: v2/mesh-quiescence # CANONICAL: https://getprobity.dev/predicate/v2/mesh-quiescence # MARKDOWN: https://getprobity.dev/predicate/v2/mesh-quiescence.md ============================================================================== # Probity Mesh-Quiescence,v2 **predicateType:** `https://getprobity.dev/predicate/v2/mesh-quiescence` A mesh-quiescence attestation is a signed, offline-verifiable statement about the **freeze-atomicity** of an isolation substrate: that a set of co-resident microVMs was brought to a hypervisor-enforced quiescent state (every guest CPU paused) with bounded, measured timing, and that no in-flight inter-VM segment escaped that freeze without being captured. Where a [security-verdict](../v1/security-verdict.md) reports what an attack corpus did to a single artifact, a mesh-quiescence attestation reports the *substrate-level* property that makes a clean freeze trustworthy: the window between cutting the inter-agent proxy and pausing the recipient VM is closed, bounded, and witnessed. This is the substrate evidence the [a2a-mesh-scorecard](a2a-mesh-scorecard.md) leans on for its freeze-tier `quiescence_sound` flag. It is also the first wave-1 predicate whose property a perimeter-only sandbox **cannot** emit: the freeze is enforced at the hypervisor memory boundary (a `KVM` vCPU pause), not at a syscall or network shim, and the captured-segment evidence is read from the host's own view of the guest socket buffers. A tool that intercepts at the application or network perimeter has no quiescent-CPU primitive and no host-side socket-buffer capture to attest. This document specifies the predicate body. The signing envelope, subject binding, and offline-verify mechanics are in [signing-envelope.md](../v1/signing-envelope.md). 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). ## Subject The subject is the **frozen-VM substrate, by MeshManifest digest** - a single digest that commits to the ordered set of per-VM byte-identity substrate-integrity roots the freeze ran over. It is NOT a post-execution snapshot digest; it is the boot-time substrate identity of the mesh that was frozen. ```json "subject": [ { "name": "", "digest": { "sha256": "<64-hex-mesh-manifest-digest>" } } ] ``` The MeshManifest digest is `SHA-256` of the canonical JSON array of the N per-VM byteident substrate-integrity digests, captured **at boot, not from the post-exec snapshot**. A producer MUST NOT synthesize a missing per-VM digest; every VM in the frozen mesh carries a real substrate-integrity digest or the attestation is refused. This binding lets a verifier link the timing-and-atomicity evidence to the exact substrate it was measured on. ## Predicate ```json { "topology": { "vm_count": 2, "netns_per_vm": true, "veth_p2p_31": true, "denied_cidrs": ["10.0.0.0/8"], "cap_net_raw_dropped": true, "ipv6_posture": "disabled" }, "deltas": { "fc_pause_ns": { "p50": 960920, "p90": 1327174, "p99": 1327174, "max": 1327174, "n": 6 }, "max_skew_ns": { "p50": 56526, "p90": 61419, "p99": 61419, "max": 61419, "n": 3 }, "proxy_stop_ns":{ "p50": 1224276,"p90": 1482445, "p99": 1482445, "max": 1482445, "n": 3 }, "tx_escape": { "window_ns": 0, "escaped_segments": 0, "recipient_state_at_escape": "Paused" } }, "mattern_ledger_sha256": "<64-hex>", "tcp_repair_queue_digests": [], "egress_policy_digest": "<64-hex>", "environment_attestations": [ { "type": "host-kernel", "claim": "honest-assumed" } ], "runs": 3, "corpus_sha256": "<64-hex>", "engine_commit": "", "fc_version": "1.16.0" } ``` ## Field semantics - **`topology`** (REQUIRED) - the network-containment facts of the frozen mesh. Each field is a fact a verifier reads but cannot itself re-derive from the predicate (they are host self-assertions carried under the signature; see the honesty section): - **`vm_count`** (REQUIRED, integer) - the number of microVMs in the mesh. - **`netns_per_vm`** (REQUIRED, boolean) - `true` iff each VM sits in its own network namespace. - **`veth_p2p_31`** (REQUIRED, boolean) - `true` iff each VM is wired with a point-to-point `/31` veth pair (no shared L2 segment). - **`denied_cidrs`** (REQUIRED, array of strings) - the CIDR ranges denied by the closed-world egress policy. - **`cap_net_raw_dropped`** (REQUIRED, boolean) - `true` iff `CAP_NET_RAW` was dropped (no raw sockets, so no source-address spoofing from inside a guest). - **`ipv6_posture`** (REQUIRED, string) - the declared IPv6 posture (for example `disabled`, or a tier-specific label). An undeclared posture is non-conforming; the field MUST be a non-empty string. - **`deltas`** (REQUIRED) - the measured freeze-atomicity timing distributions and the in-flight escape record. The three distributions each carry the **fixed key set** `{p50, p90, p99, max, n}` (nanoseconds for the timing keys; `n` is the sample count); a producer MUST emit all five keys for each distribution. - **`fc_pause_ns`** (REQUIRED, distribution) - the per-VM hypervisor pause latency (`KVM` vCPU pause) distribution. This is the freeze primitive's own cost. - **`max_skew_ns`** (REQUIRED, distribution) - the worst inter-VM pause skew: the spread between the first and last VM reaching the paused state in a single freeze. A bounded skew is what makes the freeze *atomic* across the mesh rather than a staggered shutdown. - **`proxy_stop_ns`** (REQUIRED, distribution) - the latency to stop the inter-agent proxy (the sole inter-VM channel) at the cut point. - **`tx_escape`** (REQUIRED, object) - the atomicity-window escape record. The window is the gap between cutting the proxy and pausing the recipient VM. Fields: - **`window_ns`** (REQUIRED, integer) - the measured width of that window in nanoseconds. - **`escaped_segments`** (REQUIRED, integer >= 0) - the count of inter-VM TCP segments that crossed in the window. Every escaped segment MUST be captured host-side (see `tcp_repair_queue_digests`); the invariant is "captured", not "zero" - though zero is the common clean case. - **`recipient_state_at_escape`** (REQUIRED, string) - the recipient VM's state at the moment of escape. For the atomicity invariant to hold this MUST be `Paused`: a segment delivered to a live recipient would be a real escape, not a captured-and-contained one. - **`mattern_ledger_sha256`** (REQUIRED) - the SHA-256 of the proxy's open/close ledger, the Mattern net-zero credit witness. A clean freeze requires the inter-agent channel to reach a net-zero credit state (every opened stream closed) before the pause; this digest pins that ledger so a verifier can resolve and re-hash it. - **`tcp_repair_queue_digests`** (REQUIRED, array of strings) - the host-side socket-buffer captures taken at the cut point (`T_cut`), one digest per captured queue. These are the evidence that any escaped segment was captured rather than delivered. An empty array is conforming and is the expected shape when `escaped_segments == 0`. - **`egress_policy_digest`** (REQUIRED) - the SHA-256 of the closed-world default-deny external-egress policy in force for the run. This is the precondition that makes the captured segment non-launderable: the only path a captured segment could leave by is external egress, and that path is default-deny. The digest pins the policy so a verifier can compare it against an auditor-approved policy. - **`environment_attestations`** (REQUIRED, array) - the host-trust attestations for the run. Each entry is an object with at least a `type` and a `claim`. For the honest-host tier the array carries `[{"type": "host-kernel", "claim": "honest-assumed"}]`. A hardware attestation (a SEV-SNP / TDX quote) or a zkVM receipt drops into the same slot as an additional entry with no schema break - the array is intentionally extensible so a stronger host-trust root can be added additively within v2. - **`runs`** (REQUIRED, integer) - the number of measured freeze runs the distributions summarize. - **`corpus_sha256`** (REQUIRED) - the SHA-256 pinning the run's input identity. **Honesty:** as in the [security-verdict](../v1/security-verdict.md), this pins *which* input was used, not that the input is exhaustive or adversarial; whether it is the approved set is an out-of-band auditor check. - **`engine_commit`** (REQUIRED) - an auditor-pinnable identity of the engine/substrate the freeze ran at, in the spirit of an SLSA `builder.id`. **Honesty:** an identity to compare, not a means to re-derive the producer. - **`fc_version`** (REQUIRED, string) - the hypervisor (Firecracker) version, so the pause-primitive cost numbers can be read against a known VMM build. > Forward-compatibility: per the standard's rule, a verifier MUST ignore unknown fields inside the > predicate and unknown entries inside `environment_attestations`. A producer MAY add fields > additively within v2 (for example a hardware-attestation entry). ## Checklist verdict (out of band) A producer SHOULD accompany the signed attestation with a non-signed, human-and-machine-readable verdict document recording which freeze-atomicity checklist items the run **demonstrated** versus which were **not demonstrated in this tier**. The checklist items are: | Item | Property | Read from | |---|---|---| | A1 | per-VM netns + point-to-point `/31` veth | `topology.netns_per_vm`, `topology.veth_p2p_31` | | A2 | single vsock Unix-domain socket reach per VM | host observation | | A3 | strict reverse-path filter + bound source addr + spoof-drop proven | host observation | | A4 | every escaped segment captured AND recipient `Paused` | `deltas.tx_escape` + `topology.ipv6_posture` | | A5 | external-laundering precluded by default-deny egress | `egress_policy_digest` + host observation | | B1 | boot-time byteident proofs separate from the termination snapshot | host observation | | B2 | hypervisor `Queue::save` serializes scalar ring indices only (no in-flight payload) | source assertion | | B3 | worst-case pause bounded (<= 30 s) and distributed | `deltas.fc_pause_ns` | The verdict is OUT OF BAND (it is not the signed predicate). The hard honesty rule: a verdict MUST mark any item the run did not measure as `NOT-DEMONSTRATED-THIS-TIER` - it MUST NOT tick an un-demonstrated item passed, and MUST NOT fabricate a containment number for a tier that ran no containment corpus. The signed predicate carries the *evidence* (the distributions, the digests, the topology facts); the checklist verdict is a derived reading of that evidence plus host observations. ## `quiescence_sound` `quiescence_sound` is the single boolean a downstream consumer (notably the [a2a-mesh-scorecard](a2a-mesh-scorecard.md)) reads to mean "the freeze-atomicity tier for this mesh was demonstrated sound." It is `true` iff the demonstrated checklist items (at minimum A4, B2, B3 on real VMs) passed against the measured evidence. It is NOT carried as a standalone field of this predicate; it is **derived** by a consumer from the checklist verdict over this attestation's evidence, exactly as a scorecard consumer derives a display string from structured fields. A synthetic or template attestation MUST NOT report a measured `quiescence_sound`; its flag is a template value and the consuming scorecard labels it as such (see [a2a-mesh-scorecard](a2a-mesh-scorecard.md)). ## Verification (third-party, offline) After the envelope checks in [signing-envelope.md](../v1/signing-envelope.md) succeed (signature, `keyid`, `predicateType`, subject non-empty): 1. Confirm the `subject` digest equals the MeshManifest digest the verifier intends to bind to (the boot-time per-VM byteident root set), recomputing it from the served per-VM digests if available: `SHA-256(canonical-JSON-array(per-VM digests))`. 2. Confirm every `deltas` distribution carries the full `{p50, p90, p99, max, n}` key set, and that `deltas.tx_escape` carries `window_ns`, `escaped_segments` (>= 0), and `recipient_state_at_escape`. 3. Apply the atomicity rule: `recipient_state_at_escape == "Paused"`, every escaped segment accounted for by a `tcp_repair_queue_digests` entry, and `topology.ipv6_posture` a non-empty declared string. 4. Apply the bounded-pause rule: `deltas.fc_pause_ns.max <= 30e9` ns. 5. If the consumer has an auditor-approved `egress_policy_digest` and substrate identity, confirm the predicate's values equal them (value-binding, as in the [security-verdict](../v1/security-verdict.md)). 6. Resolve and re-hash the `mattern_ledger_sha256` ledger if the consumer's policy requires the net-zero-credit witness. No live execution environment is required. ## What a verifier can and cannot independently check (honesty) - A verifier CAN confirm the attestation is authentic, bound to its frozen-substrate subject, signed by a trusted key, and internally consistent (the distributions are well-formed, the atomicity and bounded-pause rules hold over the recorded numbers, and the pinned digests are present). - A verifier CANNOT, from the attestation alone, confirm that the measured timing numbers were produced by a real freeze rather than asserted, nor that the host actually captured every escaped segment, nor that the `topology` booleans describe the real network setup. These are host self-assertions carried under the signature: the verifier learns the host *committed to* them. The attestation's guarantee is integrity, provenance, subject binding, and internal-rule consistency - not external re-execution of the freeze. The live-observation half that fills these inputs from real microVMs is the engine's job; this standard specifies the format it commits to. ## Why no perimeter sandbox can emit it (rationale) This is the standards-capture point. The properties this predicate attests live below any application- or network-perimeter interception layer: - **The pause is a hypervisor primitive.** `fc_pause_ns` / `max_skew_ns` measure a `KVM` vCPU pause that halts the guest at the memory boundary. A syscall-shim or proxy sandbox has no quiescent-CPU primitive to time; it can stop forwarding traffic but cannot freeze the guest's execution state. - **The escape evidence is host-side socket-buffer capture.** `tcp_repair_queue_digests` are read from the host's own view of the guest TCP queues at the cut point (a TCP-repair-mode snapshot). A sandbox that sees only the application's API calls has no host-side queue to snapshot. - **The atomicity window is defined by the proxy-cut-to-pause gap.** `tx_escape` only has meaning when there is both a single inter-agent proxy *and* a hypervisor pause to bound the window between. A perimeter sandbox has neither boundary. A competitor can copy this JSON shape, but it cannot fill `fc_pause_ns`, `max_skew_ns`, or a host-captured `tx_escape` with real numbers without a hypervisor-boundary freeze and host-side socket capture. The predicate format is open; the substrate that produces honest values for it is not replicable at the perimeter. ## Status and maturity - **Format and verification: stable, real.** The envelope, subject binding, distribution schema, and the offline-verify procedure are implemented and produce signed bundles that verify under the pinned key (see the conformance vectors in `conformance.json`). - **Freeze-atomicity tier: live, partial.** The shipped signed bundle was produced from a real freeze run that measured `fc_pause_ns` / `max_skew_ns` / `proxy_stop_ns` on real microVMs and a `tx_escape` with `escaped_segments == 0` / recipient `Paused`. That tier demonstrates checklist items A4, B2, B3 only. The network-containment items (A1/A2/A3/A5) and the boot-time-byteident item (B1) are **NOT-DEMONSTRATED in the freeze-atomicity tier** and belong to a separate integrated tier; the shipped verdict marks them as such and never ticks them passed. - **Hardware host-trust root: roadmap.** `environment_attestations` carries `honest-assumed` today; a SEV-SNP/TDX quote or zkVM receipt is an additive future entry, not yet live. ## Related standards - **[in-toto](https://github.com/in-toto/attestation) / [DSSE](https://github.com/secure-systems-lab/dsse)** - the envelope; see [signing-envelope.md](../v1/signing-envelope.md). - **[SLSA VSA](https://slsa.dev/spec/v1.0/verification_summary)** - `engine_commit` follows the VSA `builder.id` idea (auditor-pinnable producer identity). - **[a2a-mesh-scorecard](a2a-mesh-scorecard.md)** - the consumer that reads this attestation's freeze-atomicity soundness into a mesh composition-safety scorecard. ## Versioning ### Why v2 exists v2 renames the engine-identity field to `engine_commit`. v1 spelled that field with a vendor-specific name, which a vendor-neutral standard other implementers are expected to emit must not require. Renaming a REQUIRED key is breaking under the rule stated below, so it takes a new predicateType URI rather than a silent in-place edit: one type URI never means two shapes. There is no alias and no dual-read compatibility path, and v1 is withdrawn - a verifier reads `engine_commit` or it rejects the statement. ### Rule This is the mesh-quiescence v2 predicate. Adding a new optional field - including an additive `environment_attestations` entry (a hardware quote) or an additive `topology` fact - is additive and stays within v2; a verifier ignores fields it does not recognize. A rename or removal of a field, a change to a distribution's required key set, or a change to the `tx_escape` shape is breaking and requires a new major version. ============================================================================== # DOCUMENT: v1/catch-record # CANONICAL: https://getprobity.dev/predicate/v1/catch-record # MARKDOWN: https://getprobity.dev/predicate/v1/catch-record.md ============================================================================== # 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": "", "payloadType": "application/vnd.probity.catch-record.v2+json", "signatures": [{ "keyid": "<64-hex>", "sig": "" }] } ``` - **`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. ============================================================================== # DOCUMENT: v1/vocabulary-registry # CANONICAL: https://getprobity.dev/predicate/v1/vocabulary-registry # MARKDOWN: https://getprobity.dev/predicate/v1/vocabulary-registry.md ============================================================================== # Probity Vocabulary Registry, v1 This document is the versioned public registry of the closed enumerations used by the Probity Predicate Standard. The enumerations are `layer`, `violation_type`, and `sink`, used by the [catch-record](catch-record.md) component (and, transitively, by the verdicts that reference catch records), plus the `auth_downgrade_cause` enumeration used by the [a2a-mesh-scorecard](../v2/a2a-mesh-scorecard.md). These vocabularies are **not free text**. A conforming producer emits only registered values; a conforming verifier rejects any value not in the registry version it knows. The key words MUST, MUST NOT, SHOULD, and MAY are to be interpreted as in [RFC 2119](https://www.rfc-editor.org/rfc/rfc2119). ## Why a registry, and why fail-closed A security verdict's evidence is only meaningful if the vocabulary it uses is fixed and agreed. If a verifier silently accepted an unknown `layer` or `violation_type`, a producer could emit a value the verifier cannot interpret and the verifier would have no honest basis to trust or distrust it. So the registry is **fail-closed at a version**: - A verifier MUST reject any `layer`, `violation_type`, or `sink` value that is not present in the registry version it implements. There is no warn-and-continue mode, and none is planned. - This is the one place where the standard's general "ignore unknown" rule does NOT apply. A verifier ignores an unknown *field* (forward-compatibility), but MUST reject an unknown *enum value* for a known field. An unknown field is additive data the verifier can safely skip; an unknown enum value is a value the verifier cannot interpret and therefore cannot trust. ## Registry versioning and governance - **The registry version is tied to the predicate version.** The v1 registry below is the vocabulary for the v1 predicates. A registry revision moves in lockstep with the predicate's compatibility rules. - **Additions are registry revisions, not silent changes.** Adding a value to any of these vocabularies is a published registry revision that ships a new registry list. It is never a silent change a deployed verifier is expected to tolerate; producers and verifiers adopt the new registry version together. A renamed or removed value is a breaking change and is a new major version. - **Governance / contribution process.** Probity maintains this registry initially. Proposals to add or change a value go through a published contribution process: a `CONTRIBUTING` document describes the request format, and changes are submitted as pull requests and reviewed in the open before a new registry version is cut. The intent is to graduate registry maintenance to a neutral, multi-party process as adoption grows. Until then, all proposed changes are reviewed publicly. ## v1 registry contents ### `layer` The substrate-qualified layer that fired a catch. A value names a public abstraction of an isolation or policy layer; it does not expose any internal implementation detail. The v1 registered values are: ``` substrate.vfs_hook policy.idna policy.scrub_encodings policy.authorize_context policy.network_hook policy.engine netconfig.dns_pin netconfig.dns_ad_quorum sandbox.firecracker_isolation sandbox.overlay_limits vet.idna_recheck vet.ide_settings_recheck policy.egress_sinkhole netconfig.nftables_moat vmi.attribution quarantine.coverage ``` `substrate.vfs_hook` names the guest filesystem hook engine: the substrate-level interception point that resolves every stat, readdir, open, create, mkdir, chmod, remove, and remove_all request a guest issues against a HookAction (an ActionFunc result or a static rule) before it reaches the real filesystem. A catch at this layer is a filesystem-syscall-shaped decision, which is what separates it from every other substrate/policy layer: `policy.network_hook` decides whether a network request or response may proceed, not a filesystem operation; `policy.egress_sinkhole` captures a stream that was already admitted past the network layer; `vet.idna_recheck` is a static-analysis finding emitted by a linter pass, not a runtime block at all. Only `substrate.vfs_hook` covers the guest's file-operation surface, so a reader can trust that a `substrate.vfs_hook` catch names a filesystem decision and nothing else. `policy.idna` names the runtime hostname-canonicalization gate `NormalizeHost` applies to every policy pattern and every SNI/host header value the engine evaluates: IP-literal pre-reject, UTS #46 + IDNA 2008 encoding, the CVE-2024-12224 pre-encode integrity check, RFC 1035 label-geometry validation, and the post-IDNA round-trip check. Every `IDNA_*` violation type in this registry fires under this one layer. It is distinct from the similarly-named `vet.idna_recheck`, which is a build-time static-analysis finding about caller code that skips a DIFFERENT IDNA hazard (missing a post-`ToASCII` `net.ParseIP` recheck) and never fires at runtime; and from `netconfig.dns_pin`, which enforces a DNS-response pin match strictly after a hostname has already survived `policy.idna` normalization. Collapsing any of the three would blur a build-time linter finding, a runtime normalization rejection, and a post-resolution pin mismatch into one undifferentiated bucket. `policy.scrub_encodings` names the secret-scrubbing pass `SecretScrubber.ReplaceAll` / `ReplaceAllString` runs over an HTTP response body or SSE data line before it leaves the interceptor; the layer fires only when the pass actually replaced bytes (output differed from input), and it never records the raw secret material, only that a replacement happened. It is distinct from `policy.network_hook`, which governs whether the surrounding request or response was allowed to proceed at all — a scrub can fire on a request `policy.network_hook` already approved, because approval and content-rewriting are separate decisions; and from `policy.egress_sinkhole`, which captures and drops a stream wholesale rather than rewriting bytes within one that is still being forwarded. A scrub event is evidence of an exfiltration ATTEMPT inside allowed traffic, not evidence that the traffic itself was blocked. `policy.authorize_context` names the dialer-family host-admission gate: the phase-ordered `AuthorizeHostname` -> `FilterPinned` -> `VerifyDialedAddr` sequence that decides whether a resolved destination may be dialed at all, and it carries every `HOST_NOT_ALLOWED`, `SSRF_ATTEMPT`, `PIN_MISS`, and `PIN_IP_MISMATCH` catch record this vocabulary defines. It is distinct from `policy.network_hook`, which evaluates an already-admitted connection's request/response shape (method, path, host glob) against the L7 rule engine — a destination can pass `policy.authorize_context` and still be blocked at `policy.network_hook` moments later; and from `netconfig.dns_pin`, whose pin table binds a TLS certificate's SPKI to Quad9, a wholly different pin than the resolved-IP `PinTable` this layer enforces. Reusing `netconfig.dns_pin` for a resolved-IP mismatch would misrepresent a dialer-level admission refusal as a DNS-resolver-level certificate rejection. `policy.network_hook` names the L7 rule engine that matches an in-flight HTTP request or response against a network-hook rule's host glob, HTTP method set, and path pattern, blocking a request or response shape rather than a destination. It is distinct from `policy.authorize_context`, which decides whether the underlying destination may be dialed at all, upstream of any request being formed; a network-hook rule fires only after a connection has already cleared authorize-context admission. It is also distinct from `policy.egress_sinkhole`, which — in a sinkhole deployment — captures a request that has already cleared BOTH `policy.authorize_context` and `policy.network_hook` and would otherwise have been forwarded; a `policy.network_hook` block means the request never reached that point at all. `policy.engine` names the umbrella policy `Engine` type in `pkg/policy` that owns and coordinates the IDNA, scrub-encodings, network-hook, authorize-context, and egress-sinkhole sub-components as a single construction/configuration unit. Unlike every sibling `policy.*` layer, it has NO production emit site today: every actual policy-layer catch record is tagged with the specific sub-component that fired (`policy.idna`, `policy.scrub_encodings`, `policy.network_hook`, `policy.authorize_context`, or `policy.egress_sinkhole`), and no code path in the engine's own construction or dispatch logic emits a catch record tagged `policy.engine` directly. It is registered so a future engine-level failure — one attributable to none of the specific sub-layers — has a name to emit under without a registry revision, but as of this version it names a reserved value, not an observed catch. `netconfig.dns_pin` names the Quad9 SPKI certificate-pin enforcement on the DoT (DNS-over-TLS) upstream handshake: the `VerifyPeerCertificate`, `VerifyConnection`, and `VerifyECHRejection` closures and the shared `enforce` decay-window logic that together produce every `QUAD9_PIN_MISMATCH`, `QUAD9_PIN_EMPTY_CHAIN`, `QUAD9_PIN_ALL_MALFORMED`, and `ECH_REJECTION_UNEXPECTED` catch record. It is distinct from the resolved-IP `PinTable` enforced under `policy.authorize_context` (`PIN_MISS`, `PIN_IP_MISMATCH`) — that pin binds a hostname to the IP addresses it is allowed to resolve to, this pin binds the Quad9 resolver's own TLS certificate to a known SPKI hash, and the two mechanisms share no code path. It is also distinct from `netconfig.dns_ad_quorum`, which corroborates a DNSSEC AD-bit answer across independent resolvers rather than pinning any single resolver's certificate; the two layers defend against different adversaries (a compromised Quad9 cert versus a compromised or BGP-hijacked primary resolver) and neither can substitute for the other in signed evidence. `netconfig.dns_ad_quorum` names the cross-resolver DNSSEC AD-bit corroboration gate: when the primary resolver (Cloudflare DoH, CA-only validated) returns an `AD=1` answer, this layer fans the same query out to independent peer resolvers (NextDNS, Mullvad) within a bounded window and requires at least one to corroborate the primary's answer digest before the chain will surface it, producing every `AD_QUORUM_MISMATCH`, `AD_QUORUM_TIMEOUT`, and `AD_QUORUM_ALL_PEERS_ERRORED` catch record. It is distinct from `netconfig.dns_pin`, which pins a single resolver's own TLS certificate rather than cross-checking that resolver's answer against independent peers — a compromised primary with a perfectly valid certificate would pass `netconfig.dns_pin` but fail `netconfig.dns_ad_quorum` the moment no peer corroborates its forged answer. The two layers are deliberately independent gates on the same DNS path, not variants of one mechanism, so a verifier can tell which specific defense caught an attack. `sandbox.firecracker_isolation` names the Firecracker microVM isolation boundary itself — the hypervisor-level containment the sandbox package constructs around a guest. Unlike its sibling `sandbox.overlay_limits`, which has a live production emit site guarding the overlay-disk layer/device budget on the same VM-construction path, `sandbox.firecracker_isolation` has NO production emit site today: no code path in `pkg/sandbox` currently emits a catch record tagged with this layer. It is registered as the intended home for a future isolation-boundary catch — a hypervisor-level containment failure distinct from the disk-layout preconditions `sandbox.overlay_limits` already guards — but as of this version it names a reserved value, not an observed catch. `sandbox.overlay_limits` names the overlay-disk layout precondition check `validateOverlayDiskLayout` runs before a Firecracker microVM is launched: it enforces the 20-lower-layer budget and the 24-block-device Firecracker ceiling (root + lowers + upper + extra), producing `OVERLAY_LAYER_LIMIT` and `OVERLAY_DISK_LIMIT`. It is distinct from `sandbox.firecracker_isolation`, the sibling layer name reserved for the hypervisor isolation boundary itself rather than the disk-layout precondition that must hold before that boundary is even constructed — `sandbox.overlay_limits` fires and prevents the guest VM's `exec.Command` launch from ever happening, so no isolation boundary exists yet to have failed. Keeping the two names distinct preserves the fact that a disk-budget rejection is not an isolation failure. `vet.idna_recheck` names the static analyzer that enforces a post-encode-recheck contract on CALLER code: after any call to `idna.ToASCII` (package-level or via a `*idna.Profile`), the analyzer requires a `net.ParseIP` or `netip.ParseAddr` recheck within the next ten statements of the same block, or an explicit `//nolint:idnarecheck` suppression, and it emits `VET_IDNA_RECHECK_MISSING` through `trace.EmitGlobal` when neither is present. It is distinct from `policy.idna`, which is the runtime layer that performs IDNA normalization and already runs its OWN internal IP-literal rechecks inside `NormalizeHost` — `vet.idna_recheck` exists precisely because code OUTSIDE `NormalizeHost` can call `idna.ToASCII` directly and skip that protection, and a build-time catch here is what proves such a caller was checked at all. A `vet.idna_recheck` catch is a source-code finding at analysis time, never a runtime block of a live request. `vet.ide_settings_recheck` names the `idesettings` static analyzer (`ide_settings_recheck.go`), which models CVE-2025-68433 (the Zed editor trust-boundary issue): it flags a value loaded from a per-project IDE settings file (`.zed/settings.json`, `.vscode/settings.json`, `.cursor/settings.json`) that reaches a privileged sink (`os/exec.Command`, `syscall.Exec`, `os.WriteFile`, `net/http.Get`, `net.Dial`, and siblings) without an intervening `Validate`/`Recheck`/`Verify`/`Allowlist`/`Sanitize`/`AssertSafe`-named call. The analyzer is real and runs — it reports every finding via `pass.Reportf()` — but unlike its sibling `vet.idna_recheck`, it carries NO `trace.EmitGlobal` call anywhere in its implementation: it is structurally disconnected from the runtime catch-record vocabulary this registry governs. `VET_IDE_SETTINGS_DRIFT` is therefore a registered value with a real, running producer whose findings never reach a signed catch record today — the analyzer and the registry both exist, but nothing wires one to the other yet. `policy.egress_sinkhole` names the userspace capture-and-drop interception point: in sinkhole mode, the upstream connection for a guest's dial is a local capture pipe rather than the real destination, and this layer records the signed `EGRESS_SINKHOLE_CAPTURE` catch — carrying the intended pre-DNAT destination and the scrubbed payload — immediately before the captured stream is dropped, across all three legs (raw TCP, HTTP cleartext, HTTPS). It is distinct from `netconfig.nftables_moat`, which is a KERNEL-level unconditional forward-chain drop that requires no user-space component to have run at all; `policy.egress_sinkhole` is precisely the opposite shape — a user-space capture that requires the connection to have already cleared every upstream policy gate and reached the proxy. It is also distinct from `policy.network_hook` and `policy.authorize_context`, both of which can block a request before it ever reaches the sinkhole; a `policy.egress_sinkhole` catch means those upstream gates already admitted the traffic and the sinkhole is the last line recording what would otherwise have reached the real destination. `netconfig.nftables_moat` names the kernel packet-filter ruleset that the substrate stages around a sandbox's tap: the forward chain a guest's traffic must traverse, whose terminal verdict is deny. A catch at this layer is a verdict the KERNEL reached, before and independently of any user-space component. No existing layer can carry it, because every other layer names a user-space component that need not have run at all for the packet to be dropped: `policy.authorize_context` asserts that a host-admission decision was made, but a dropped packet may carry no host semantics to decide over (a conntrack-invalid retransmit, a neighbour-discovery frame, a raw ethertype); `netconfig.dns_pin` names the rebind-pin table specifically; `policy.egress_sinkhole` asserts that a stream was terminated and captured. Reusing any of them would place a mechanism into signed evidence that did not execute, which is precisely what a closed vocabulary exists to prevent. The last two (`vmi.attribution`, `quarantine.coverage`) name the block-without-kill evidence-record classes rather than a layer that fired a catch: an owning-task attribution record and a coverage-closure snapshot. Both are catch-record-v1 records that ride the drained evidence stream, so they belong in the closed layer vocabulary a verifier validates. See [catch-record](catch-record.md) for their record shapes. A verifier MUST reject any `layer` value not in this list. A renamed layer is a breaking change (new major version). ### `violation_type` The symbolic reason a catch fired. A given `violation_type` MAY legitimately appear under more than one `layer`; the registry does not constrain which violation types co-occur with which layers. The v1 registered values are: ``` PATH_ESCAPE_DENIED VFS_HOOK_BLOCKED IDNA_IP_LITERAL_BLOCKED IDNA_INTEGRITY_FAILED IDNA_MALFORMED_LABEL IDNA_TOO_LONG IDNA_EMPTY_WILDCARD ENCODED_PAYLOAD_SCRUBBED HOST_NOT_ALLOWED SSRF_ATTEMPT NETWORK_HOOK_BLOCKED PIN_MISS PIN_IP_MISMATCH PIN_EXPIRED QUAD9_PIN_MISMATCH QUAD9_PIN_EMPTY_CHAIN QUAD9_PIN_ALL_MALFORMED AD_QUORUM_MISMATCH AD_QUORUM_TIMEOUT AD_QUORUM_ALL_PEERS_ERRORED ECH_REJECTION_UNEXPECTED OVERLAY_LAYER_LIMIT OVERLAY_DISK_LIMIT VET_IDNA_RECHECK_MISSING VET_IDE_SETTINGS_DRIFT EGRESS_SINKHOLE_CAPTURE MOAT_PACKET_DROPPED EGRESS_OWNING_TASK_RESOLVED QUARANTINE_COVERAGE ``` `PATH_ESCAPE_DENIED` names a guest path resolution that would climb above its mount root (a `..`-relative traversal caught by `resolveName`'s `os.Root`-style confinement in `pkg/vfs/realfs.go`, refused with `EPERM` rather than silently rewritten). The refusal itself is real and enforced today — `TestEscapeRefusalReportsEPERM` pins it — but it has NO production emit site: `resolveName` and its `confineError` wrapper return the refusal to the caller without ever calling `trace.Emit` or `trace.EmitGlobal`, so a path-escape refusal produces no signed catch record in the current codebase. This is distinct from its sibling `VFS_HOOK_BLOCKED`, which IS wired end-to-end (`pkg/vfs/hooks.go`'s `emitHookBlock`) for every other guest filesystem refusal — stat, readdir, open, create, mkdir, chmod, remove, remove_all. `PATH_ESCAPE_DENIED` is registered because a path-escape refusal is a distinct violation class from a hook-engine block, but as of this version it names a reserved value with no observed emission. `VFS_HOOK_BLOCKED` reports that the guest filesystem hook engine resolved a request — stat, readdir, open, create, mkdir, chmod, remove, or remove_all — to `HookActionBlock`, either through a caller-supplied `ActionFunc` or a static block rule, and `emitHookBlock` (`pkg/vfs/hooks.go`) recorded it before the operation reached the real filesystem. It is the general-purpose catch for every VFS-hook refusal, in contrast to its narrower sibling `PATH_ESCAPE_DENIED`, which names one specific refusal reason (a `..` traversal) and — unlike `VFS_HOOK_BLOCKED` — currently emits no record at all. It is also distinct from `NETWORK_HOOK_BLOCKED`, the network-layer analog fired by the L7 rule engine under `policy.network_hook`; the two share a naming pattern but block entirely different primitives (a filesystem syscall versus a network request/response). `IDNA_IP_LITERAL_BLOCKED` reports that `NormalizeHost` rejected a value because it is (or, after UTS #46 NFKC-style digit folding, becomes) an IP-literal rather than a DNS name — fired at three points in the pipeline: the pre-encode literal check, the wildcard-body re-check, and the post-IDNA-mapping re-check that exists specifically because non-ASCII digit sequences (superscripts, fullwidth digits) can fold into an ASCII IPv4 literal after `ToASCII` (the class `FuzzNormalizeHost` discovered, regression seed `5602a60fa23a9bf8`). It is distinct from `IDNA_MALFORMED_LABEL` (a structurally invalid DNS label under RFC 1035 section 2.3.4 — empty labels, over-length labels, all-dots input) and `IDNA_TOO_LONG` (a well-formed name that exceeds the 253-octet FQDN cap): an IP literal is a categorically different kind of value than a malformed or oversized DNS name, and the policy engine's IP-CIDR surface and DNS-name surface must never be conflated, which is exactly what this violation type exists to keep separate. `IDNA_INTEGRITY_FAILED` reports one of three distinct integrity failures `NormalizeHost` can detect over the same input: a pre-encode `xn--`-prefixed label whose decoded form is pure ASCII (the CVE-2024-12224 pattern Go's `idna` library silently accepts but Python's does not, so Probity pre-checks the INPUT itself before encoding); a bare `ToASCII` encoding error from the UTS #46 + IDNA 2008 profile; or a failed encode-decode-re-encode round trip, which catches any non-idempotent output a future Unicode table update might introduce even after `CheckHyphens` already rejects the known `xn--example-` pattern. All three share this one violation type because each is an INTEGRITY failure — the library or the input behaved inconsistently — as opposed to `IDNA_MALFORMED_LABEL`, which reports a structurally well-formed-but-invalid DNS label (RFC 1035 section 2.3.4 geometry: empty labels, 64-octet labels, all-dots input) where the encoding itself succeeded honestly. `IDNA_MALFORMED_LABEL` reports that `validateACELabels` rejected the post-`ToASCII` ASCII-compatible-encoding form for violating RFC 1035 section 2.3.4 DNS label geometry — empty labels, an all-dots input, or a label exceeding the 63-octet limit — rules that `golang.org/x/net/idna` does not itself enforce because it short-circuits pure-ASCII labels through LDH-only validation. It is distinct from `IDNA_TOO_LONG`, which caps the fully-assembled FQDN (including any wildcard prefix) at 253 octets and fires only after label geometry has already passed; a name can fail `IDNA_MALFORMED_LABEL` on a single 64-octet label while being well under the 253-octet total, and conversely pass every per-label geometry check yet still be rejected as `IDNA_TOO_LONG` once assembled. The two violation types cover RFC 1035's per-label and whole-name length rules respectively, and neither subsumes the other. `IDNA_TOO_LONG` reports that the fully-assembled result of `NormalizeHost` — the wildcard prefix (`*.`, if present) plus the canonical ASCII-compatible-encoding form — exceeds the RFC 1035 253-octet FQDN limit, checked after label geometry (`IDNA_MALFORMED_LABEL`) has already passed and before the final round-trip integrity check (`IDNA_INTEGRITY_FAILED`) runs. It is a whole-name budget, not a per-label one: a name assembled entirely from valid, correctly-sized labels can still fail `IDNA_TOO_LONG` once the label count pushes the total over budget, which is exactly why it is a separate violation type from `IDNA_MALFORMED_LABEL` rather than a variant of it — the two check different RFC 1035 constraints (label shape versus total length) at different stages of the same pipeline. `IDNA_EMPTY_WILDCARD` reports that a value entered `NormalizeHost` with wildcard matching enabled, began with the `*.` prefix, and had nothing left after the prefix was stripped — a pattern like `*.` with no host body to match against. It fires early in the pipeline, before any IDNA encoding is attempted, which is what distinguishes it from every other `IDNA_*` violation type: `IDNA_IP_LITERAL_BLOCKED`, `IDNA_MALFORMED_LABEL`, `IDNA_TOO_LONG`, and `IDNA_INTEGRITY_FAILED` all evaluate the CONTENT of a hostname, whereas `IDNA_EMPTY_WILDCARD` fires on the absence of content — a degenerate pattern that would otherwise either error obscurely deeper in the pipeline or, worse, be silently treated as matching every possible host. `ENCODED_PAYLOAD_SCRUBBED` reports that `SecretScrubber.ReplaceAll` or `ReplaceAllString` found and replaced secret bytes inside an HTTP response body or SSE data line — the record fires only when the scrubbed output actually differs from the input, and it deliberately never carries the raw secret material or even its byte length in any signed or quantified field, only the fact that a replacement happened. It is distinct from `EGRESS_SINKHOLE_CAPTURE`, which captures and drops an entire stream rather than rewriting bytes within one that continues onward: an `ENCODED_PAYLOAD_SCRUBBED` record describes traffic that WAS still forwarded, with the secret redacted in flight, whereas an `EGRESS_SINKHOLE_CAPTURE` record describes traffic that never reached its intended destination at all. The two violation types therefore report opposite dispositions of an in-flight secret — sanitized-and-forwarded versus captured-and-dropped. `HOST_NOT_ALLOWED` is the generic host-admission denial: a destination that failed the allowlist check for any reason other than resolving to a private, link-local, or IMDS address. It fires from every `policy.authorize_context` call site that refuses a host — an empty resolved-address set, an `AllowedIPs`-filtered survivor set of zero, or a bare `IsHostAllowed` observation reported through `EmitHostDenyObservation` — and it is also the value `hostDenyViolation` falls back to whenever `BlockPrivateIPs` is off or the rejected address is not itself private. It is distinct from `SSRF_ATTEMPT`, its own specialization: when `BlockPrivateIPs` is enabled and every surviving resolved address was dropped specifically because it is private/link-local/IMDS, `hostDenyViolation` reports `SSRF_ATTEMPT` instead, because a hostname resolving into the private network inside a sealed gate is a server-side-request-forgery signal the generic bucket would otherwise hide from downstream triage. `SSRF_ATTEMPT` is the specialization of `HOST_NOT_ALLOWED` for an egress whose resolved destination is a private, link-local, or loopback address (RFC 1918, `169.254.0.0/16` IMDS, and the rest of the `privateRanges` set) denied under `BlockPrivateIPs`. `hostDenyViolation` and `FilterPinned` choose this value instead of the generic `HOST_NOT_ALLOWED` precisely because, in a sealed gate, the system under test has no legitimate private-network destination — so a hostname or address that lands in the private range is a server-side-request-forgery or metadata-exfiltration probe, not merely an un-allowlisted public host. Emitting it as a distinct value lets a downstream triage pipeline auto-classify the attempt instead of collapsing every allowlist denial into one undifferentiated `HOST_NOT_ALLOWED` bucket, which is the whole reason the two values are kept separate rather than merged. `NETWORK_HOOK_BLOCKED` reports that the L7 network-hook rule engine matched an in-flight request or response against a rule's host glob, HTTP method set, or path pattern and blocked it, recorded by `emitNetworkBlock` under the `policy.network_hook` layer with `SinkBlocked` fixed at `Dial`. It is distinct from `HOST_NOT_ALLOWED`, which is decided earlier and at a coarser grain — whether the destination may be dialed AT ALL, before any request has even been formed — whereas `NETWORK_HOOK_BLOCKED` can fire against a destination that already passed host-admission, on the finer-grained shape of the specific request (its method or path). It is also distinct from its filesystem-layer namesake `VFS_HOOK_BLOCKED`: the two share a naming convention (`*_HOOK_BLOCKED`) but block entirely different primitive classes, and neither vocabulary entry implies anything about the other's layer. `PIN_MISS` reports that `FilterPinned` found a populated `PinTable` but no entry at all for the canonicalized host being authorized — every resolved address survived to this point, but the pin table has nothing recorded for this hostname to check them against. It is distinct from `PIN_IP_MISMATCH`, which reports the opposite shape: an entry EXISTS for the host, but the resolved (or, at `VerifyDialedAddr`, the actually-dialed) address is not among the pinned addresses for it. `PIN_MISS` is therefore a missing-registration failure and `PIN_IP_MISMATCH` is a wrong-address failure over an existing registration — a verifier reading either can tell immediately whether the pin table knew the host at all. `PIN_IP_MISMATCH` reports that a `PinTable` entry exists for the host but the address under test — either the intersection of resolved addresses at `FilterPinned` or the actually-dialed address re-checked at `VerifyDialedAddr`'s `ControlContext` callback — is not among the pinned set for it. It is distinct from `PIN_MISS` (no entry exists for the host at all, so there is nothing to mismatch against) and from the registered-but-unemitted `PIN_EXPIRED`, which `PinTable.Verify`'s documented contract reserves for an entry that exists but has aged out (`ErrPinExpired`) rather than one whose addresses simply disagree with the ones under test — today every `ErrPinExpired` return reaches the caller as a Go error without a corresponding catch record, so `PIN_IP_MISMATCH` remains the only address-disagreement value actually observed in signed evidence. `PIN_EXPIRED` names the pin-table outcome `PinTable.Verify`'s documented contract calls entry expired: a populated pin exists for the host, but its recorded validity window has aged out (`ErrPinExpired`, per `VerifyDialedAddr`'s doc comment at `pkg/policy/authorize_context.go`). The error path exists in the type's documented contract, but NO call site in the current codebase maps an `ErrPinExpired` return to `emitAuthorizeReject(trace.ViolationPinExpired, ...)` or any other `trace.EmitGlobal` call — unlike its siblings `PIN_MISS` and `PIN_IP_MISMATCH`, which both have live emit sites in `FilterPinned` and `VerifyDialedAddr`, `PIN_EXPIRED` has NO production emit site today. It is registered because pin expiry is a distinct outcome from a missing entry or a mismatched address, but as of this version it names a reserved value with no observed emission. `QUAD9_PIN_MISMATCH` reports that `enforce()` computed the SHA-256 of at least one usable certificate's `RawSubjectPublicKeyInfo` in the presented chain but none matched the embedded Quad9 SPKI pin set, checked only after the graceful-decay window (`GracefulDecayDays`, the default beyond which a non-`HardFail` policy accepts CA-trust alone) has NOT yet elapsed. It is distinct from `QUAD9_PIN_ALL_MALFORMED`, which fires earlier in `enforce()` when the chain contains zero usable certificates at all (nothing to hash), and from `QUAD9_PIN_EMPTY_CHAIN`, which fires even earlier, at the wrapper level, when the wire itself carried no certificates (`rawCerts` or `PeerCertificates` empty) before `enforce()` is ever called. The three violation types mark three different points at which a Quad9 DoT handshake can fail to produce a trusted, pin-matching certificate. `QUAD9_PIN_EMPTY_CHAIN` reports that the TLS stack handed `VerifyPeerCertificate` or `VerifyConnection` a wire-level empty certificate chain (`rawCerts` or `cs.PeerCertificates` has zero entries) before `enforce()`'s pin logic ever runs. This is documented as wire-side semantics — a stdlib glitch or genuine peer misbehavior at the transport level — which is what distinguishes it from `QUAD9_PIN_ALL_MALFORMED`: that value fires INSIDE `enforce()` when the wire delivered cert bytes but every one of them failed to parse into a usable certificate (parse-side semantics: an attacker-shaped chain designed to look non-empty while carrying nothing `enforce()` can hash). The distinction matters operationally — an empty chain and an all-malformed chain point an incident responder toward different root causes even though both result in the same hard failure. `QUAD9_PIN_ALL_MALFORMED` reports that `enforce()` received a chain of one or more certificates but none had a usable `RawSubjectPublicKeyInfo` to hash — a chain shape that is malformed regardless of the decay window's state, checked and rejected before the decay short-circuit runs so a wire that carried bytes but produced zero usable certs cannot silently fall through to a decay-window CA-trust accept at day 90+. It is distinct from `QUAD9_PIN_EMPTY_CHAIN`, which is the wire-level empty-chain case caught by the wrapper closures before `enforce()` is even invoked (nothing arrived at all, versus something arrived that could not be used), and from `QUAD9_PIN_MISMATCH`, which fires only after usable certificates ARE found but none of them match the pin set. `QUAD9_PIN_ALL_MALFORMED` sits between the two: certificates arrived, but none of them were usable enough to even attempt a pin comparison. `AD_QUORUM_MISMATCH` reports that every peer resolver responded (or errored) within the quorum window, at least one peer response was NOT an error, and yet no peer's answer corroborated the primary's `AD=1` digest — the peers were reachable and answered, but they disagreed with (or among) themselves rather than confirming the primary. It is distinct from `AD_QUORUM_TIMEOUT`, which fires when the bounded window expires before ANY peer responds at all — an infrastructure-silence signal rather than a disagreement signal — and from `AD_QUORUM_ALL_PEERS_ERRORED`, which fires when every single peer returned a transport error rather than an answer. The three violation types deliberately separate 'peers disagreed' (suspicious — possible primary compromise) from 'peers were silent' and 'peers were unreachable' (both infrastructure problems), because an operator reading the metric stream needs to tell those apart. `AD_QUORUM_TIMEOUT` reports that the bounded cross-quorum window (`DefaultADQuorumWindow`, 200ms in production) elapsed before any peer resolver produced a result at all, so the primary's `AD=1` answer has zero corroboration to evaluate. It is distinct from `AD_QUORUM_ALL_PEERS_ERRORED`, which requires every peer to have actively returned before the window closed, each with a transport error — timeout means the peers never got the chance to answer either way, whereas all-peers-errored means they all did answer, and every answer was a failure. It is also distinct from `AD_QUORUM_MISMATCH`, where peers DID respond in time but simply failed to corroborate; `AD_QUORUM_TIMEOUT` specifically names the case where the window itself, not the peers' answers, is what closed the query. `AD_QUORUM_ALL_PEERS_ERRORED` reports that every peer resolver in the quorum set returned a transport error within the window rather than any of them answering — the DoH transport to every configured peer failed, not merely disagreeing with the primary. It is distinct from `AD_QUORUM_TIMEOUT`, where the window expired without ANY peer result (error or success) arriving in time, and from `AD_QUORUM_MISMATCH`, where at least one peer DID return a non-error result but none corroborated the primary. The distinction between all-errored and timeout is deliberate: an operator monitoring the metric stream needs to tell 'the peer DoH transport is broken' apart from 'nothing came back in the window at all', and collapsing the two into one value would erase that diagnostic signal. `ECH_REJECTION_UNEXPECTED` reports that the stdlib TLS stack invoked `VerifyECHRejection` on the Quad9 DoT connection at all — Probity does not adopt Encrypted Client Hello on this path (the DNS-bootstrap chicken-and-egg problem plus a moot privacy gain for a service-controlled outbound client), so under the default `ECHReject` policy this closure treats its own invocation as a hard failure regardless of what the server's rejection actually contained. It is distinct from every other Quad9 violation type (`QUAD9_PIN_MISMATCH`, `QUAD9_PIN_EMPTY_CHAIN`, `QUAD9_PIN_ALL_MALFORMED`) because those all fire from cert-chain inspection inside `VerifyPeerCertificate` or `VerifyConnection`, while an ECH rejection SUPPRESSES both of those callbacks entirely — `ECH_REJECTION_UNEXPECTED` is the only Quad9 catch that can fire without either of the other two ever having had a chance to run. `OVERLAY_LAYER_LIMIT` reports that `validateOverlayDiskLayout` rejected a sandbox construction because the requested lower-layer count exceeds `maxOverlayLowerLayers` (20), checked first and independently of the total device count. It is distinct from `OVERLAY_DISK_LIMIT`, which is a separate budget over the TOTAL block-device count (root + lowers + upper + extra, capped at Firecracker's 24-device ceiling) checked only after the layer-count check has already passed — a request can fail `OVERLAY_LAYER_LIMIT` alone (too many lowers, even before adding root/upper/extra), or pass it and still fail `OVERLAY_DISK_LIMIT` once the fixed overhead pushes the total over budget. Keeping the two separate lets a reader tell which specific budget (layer count versus total device count) the request violated. `OVERLAY_DISK_LIMIT` reports that `validateOverlayDiskLayout` rejected a sandbox construction because the total block-device count — root plus lower layers plus the upper writable layer plus any extra user-provided disks — exceeds Firecracker's 24-device ceiling, checked after the `OVERLAY_LAYER_LIMIT` layer-count budget has already passed. It is the total-device budget as opposed to `OVERLAY_LAYER_LIMIT`'s lower-layer-only budget: a request within the 20-layer limit can still exceed the 24-device ceiling once the fixed root/upper overhead and any extra disks are added in, which is exactly the case this violation type exists to catch separately. `VET_IDNA_RECHECK_MISSING` reports that the `idnarecheck` static analyzer found a call to `idna.ToASCII` whose result is not re-checked with `net.ParseIP` or `netip.ParseAddr` within the next ten statements of the enclosing block, and is not suppressed with a `//nolint:idnarecheck` comment — the UTS #46 NFKC digit-folding hazard that lets a visually-distinct Unicode digit sequence encode into an ASCII string that parses as an IP literal. It is the only violation type this vocabulary registers that is emitted from a `go/analysis` pass rather than from a runtime code path, and it is distinct from the runtime `IDNA_IP_LITERAL_BLOCKED`: that value reports a live rejection INSIDE `NormalizeHost`'s own internal recheck, whereas `VET_IDNA_RECHECK_MISSING` reports that some OTHER caller's code, outside `NormalizeHost`, was never proven to have that same protection at all. `VET_IDE_SETTINGS_DRIFT` names the diagnostic the `idesettings` analyzer reports (`ide_settings_recheck.go`) when a value loaded from a per-project IDE settings file reaches a privileged sink without an intervening revalidation call, modeling CVE-2025-68433. The analyzer is real, runs today, and reports every finding through `pass.Reportf()` with a detailed diagnostic message — but it never constructs a `trace.CatchRecord` or calls `trace.EmitGlobal`, unlike its sibling `VET_IDNA_RECHECK_MISSING`, whose analyzer (`idnarecheck`) DOES emit through `trace.EmitGlobal` alongside its `pass.Reportf` call. `VET_IDE_SETTINGS_DRIFT` is therefore registered vocabulary with a genuine, currently-running producer, but that producer is structurally disconnected from the signed catch-record path this registry governs — a finding reaches the `go vet`-style output stream and nowhere else today. `EGRESS_SINKHOLE_CAPTURE` reports that, in sinkhole deployment mode, a guest's dial to a policy-gated destination was routed to a local capture-and-drop pipe instead of the real upstream, and the intended pre-DNAT destination plus the scrubbed payload were recorded before the stream was dropped — the one violation type shared across all three sinkhole legs (raw TCP, HTTP cleartext, HTTPS), distinguished from each other only by the accompanying `sink` value (`tcp.Passthrough`, `Dial`, `tls.Handshake`). It is distinct from `MOAT_PACKET_DROPPED`, the kernel-level forward-chain drop that requires no captured payload and no user-space component to have run; and from `ENCODED_PAYLOAD_SCRUBBED`, which redacts secret bytes inside traffic that is still forwarded rather than capturing and dropping the whole stream. `EGRESS_SINKHOLE_CAPTURE` is also one of the four violation types (alongside `HOST_NOT_ALLOWED`, `SSRF_ATTEMPT`, and `NETWORK_HOOK_BLOCKED`) the quarantine block-without-kill arm binds on, because a captured egress carries the destination sink tuple the arm needs to attribute a blocked flow to its owning guest task. `MOAT_PACKET_DROPPED` reports that the kernel discarded a packet at the forward hook. It is distinct from the three violation types a reader might otherwise reach for, each of which asserts something stronger than a drop: `HOST_NOT_ALLOWED` asserts an allowlist verdict reached over a resolved name or address; `NETWORK_HOOK_BLOCKED` asserts a decision by the L7 rule engine; `EGRESS_SINKHOLE_CAPTURE` asserts a capture that, for a dropped packet, did not happen. One violation type covers every kernel drop, and the producer's internal drop taxonomy is deliberately NOT mirrored into this registry. A published cross-language vocabulary must not become the bottleneck on ordinary substrate hardening: a producer that adds, splits or retires a drop rule would otherwise need a registry revision, and its verifiers would need to adopt it, before the rule could ship. Producers MAY carry a finer internal classification in their own unsigned telemetry, and MAY ride the split on the OPTIONAL `sink` when one applies, exactly as `EGRESS_SINKHOLE_CAPTURE` distinguishes its L4 and L7 legs through `sink` rather than through two violation types. `EGRESS_OWNING_TASK_RESOLVED` is `CatchRecord.ViolationType` for a `vmi.attribution` record: the SECOND signed catch record in the block-without-kill evidence pair, joined to the triggering egress catch (one of `HOST_NOT_ALLOWED`, `SSRF_ATTEMPT`, `NETWORK_HOOK_BLOCKED`, or `EGRESS_SINKHOLE_CAPTURE`) by `catch_seq`, attributing the blocked-but-non-terminal egress to the guest task and `ModelBOM` that owned it via a paused-guest socket walk. It is distinct from every violation type above it in this list in that it does not itself describe a policy refusal — the refusal already happened and was already recorded under its own layer — this value describes a SEPARATE, subsequent act of attribution over that refusal. It is also distinct from `QUARANTINE_COVERAGE`, its sibling block-without-kill record type: `EGRESS_OWNING_TASK_RESOLVED` is a per-catch attribution record, one per blocked egress, while `QUARANTINE_COVERAGE` is a run-level accounting snapshot covering every catch the arm has seen. `QUARANTINE_COVERAGE` is `CatchRecord.ViolationType` for a `quarantine.coverage` record: a run-level snapshot of the block-without-kill arm's own bookkeeping — how many catches it saw versus attributed versus suppressed, under a strict equality closure (`catches_seen == attributed + dedup_suppressed + budget_suppressed + breaker_suppressed + pending_overflowed + pending_unserviced + not_bindable + internal_faulted + in_flight + queued`). It is explicitly a vocabulary marker, not an offense: the record accounts the arm's own work rather than reporting a caught egress, which is what separates it from every other violation type in this registry — `HOST_NOT_ALLOWED` through `EGRESS_OWNING_TASK_RESOLVED` all describe something the substrate caught the guest doing, while `QUARANTINE_COVERAGE` describes whether the arm's own accounting is complete and trustworthy up to its last signed flush. A verifier MUST reject any `violation_type` value not in this list. ### `sink` The syscall, dial, or exec primitive that was prevented. This vocabulary is OPTIONAL in a catch record (a catch record MAY omit `sink`), but when present the value MUST be a registered one. The v1 registered values are: ``` openat Dial exec.Command vfs.Hook tls.Handshake dns.Exchange tcp.Passthrough netfilter.Forward ``` `openat` names the sink `emitHookBlock` (`pkg/vfs/hooks.go`) always records for a `substrate.vfs_hook` block, regardless of which specific hook operation fired — stat, readdir, open, create, mkdir, chmod, remove, and remove_all all record `openat`, not a syscall-accurate name per operation. It stands for the guest filesystem hook's file-primitive family as a whole rather than the literal `openat(2)` syscall for every one of those ops. It is distinct from `vfs.Hook`, a differently-worded sink that — despite the name — is NOT emitted by the VFS hook layer at all; its sole production emission is under `policy.scrub_encodings`. A reader must not assume `vfs.Hook` and `openat` are interchangeable names for the same primitive family; they are emitted by entirely different layers today. `Dial` names the network-dial primitive prevented from reaching its destination, and it is the fixed sink for every `policy.authorize_context` denial (`emitAuthorizeReject` hardcodes it, since every call site in that file is a dialer-family phase method), for every `NETWORK_HOOK_BLOCKED` record, and for the HTTP-cleartext leg of an `EGRESS_SINKHOLE_CAPTURE` (the sinkhole intercepts the `Write` that would otherwise reach the real destination). It is distinct from `tcp.Passthrough`, the sinkhole's raw-TCP leg sink, and from `tls.Handshake`, the sinkhole's HTTPS leg sink — the three sinks let a forensic reader tell which of the three sinkhole legs (cleartext HTTP, raw TCP, or TLS-terminated HTTPS) captured a given egress, even though all three can carry the same `EGRESS_SINKHOLE_CAPTURE` violation type. `exec.Command` names the process-launch primitive an `OVERLAY_LAYER_LIMIT` or `OVERLAY_DISK_LIMIT` rejection prevents: `validateOverlayDiskLayout` runs before the sandbox's Firecracker microVM process is ever launched, so a disk-layout rejection means the `exec.Command` invocation that would start the VMM binary never happens at all. It is distinct from `Dial`, `vfs.Hook`, and `tls.Handshake`, which all name primitives prevented at RUNTIME against an already-running guest; `exec.Command` is the only sink in this registry that names a primitive prevented before a guest sandbox exists to run anything, at the moment of sandbox construction itself. `vfs.Hook` names the hook-mediated content-interception point at which `SecretScrubber.ReplaceAll` / `ReplaceAllString` rewrites bytes; its sole production emission today is under `ENCODED_PAYLOAD_SCRUBBED` at the `policy.scrub_encodings` layer, invoked from the HTTP response body and SSE data-line scrub calls in `pkg/policy/network_hooks.go`. This is the one place in the registry where a sink's name and its actual emitting layer diverge from what the name suggests: `substrate.vfs_hook` — the layer whose name most resembles `vfs.Hook` — records `openat` as its sink, never `vfs.Hook`. A reader must resolve `vfs.Hook` by its actual emission site (a content-scrub interception point) rather than by name association with the VFS hook layer, which is a distinct, unrelated mechanism. `tls.Handshake` names the TLS handshake primitive, and it is emitted from two unrelated call sites that happen to share the same primitive name: the Quad9 SPKI pin verifier (`emitPinReject` in `pkg/netconfig/dns_pin_quad9.go`), where it marks the DoT upstream handshake a pin rejection prevented from completing; and the HTTPS leg of an egress-sinkhole capture (`emitSinkholeCapture` in `pkg/netconfig/http.go`), where it marks the upstream TLS leg the sinkhole stood in for. The two emission sites carry different layers and different violation types (`netconfig.dns_pin` / `QUAD9_PIN_*` versus `policy.egress_sinkhole` / `EGRESS_SINKHOLE_CAPTURE`), so a verifier must read `layer` and `violation_type` together with `sink` to tell a Quad9 pin failure from a captured HTTPS egress — `tls.Handshake` alone under-determines which of the two occurred. `dns.Exchange` names the DNS query/response exchange primitive, and it is emitted from three distinct call sites: every `IDNA_*` rejection (`emitIDNAReject` fixes this sink for the whole `policy.idna` layer, since a rejected hostname was never going to reach a DNS exchange with that value), the DNS-proxy allowlist chokepoint's `HOST_NOT_ALLOWED` observation (`policy.EmitHostDenyObservation` in `pkg/netconfig/dns.go`, for a name or CNAME target the DNS-side allow-predicate refused), and every `AD_QUORUM_*` rejection (`emitQuorumReject` in `pkg/netconfig/dns_ad_quorum.go`). All three call sites genuinely prevented a DNS exchange from completing on the attacker's terms, unlike `vfs.Hook`, whose name-to-emission-site relationship is a documented exception; `dns.Exchange` is a straightforward, literal match between its name and what it records across every site that emits it. `tcp.Passthrough` marks an egress-sinkhole capture on the raw, non-HTTP TCP passthrough path: a guest dialled a policy-gated TCP port that is not HTTP/HTTPS, so there is no request line to parse — the sinkhole captured the raw stream and dropped it. It is distinct from `Dial` (the HTTP cleartext sinkhole leg) so a forensic reader can tell an L4 raw exfil (e.g. a database `DROP TABLE` to port 5432) from an L7 HTTP capture. `netfilter.Forward` names the packet-forwarding primitive itself: the prevented operation is forwarding the packet onward, and every rule that can reach a drop verdict sits on the forward chain. It is registered so that a kernel drop is not the one egress record with no `sink` at all — `sink` is a field forensic consumers pivot on, and a whole record class that never carries one reads as missing data rather than as an answered question. A verifier MUST reject any present `sink` value not in this list. ### `task_resolution` How a `vmi.attribution` catch record's egress-owning task was resolved from the paused-guest socket walk. Present in the record's `task_resolution` field and summed into the `quarantine.coverage` record's `task_resolution` histogram. The v1 registered values are: ``` 4-tuple dst-fallback dst-only ``` `4-tuple` is the strongest binding (the owning task was matched on the full source/destination address 4-tuple); `dst-fallback` matched on the destination alone after the source could not be recovered; `dst-only` is a destination-only attribution with no source corroboration. A verifier MUST reject any `task_resolution` value not in this list. ### `verity_device_pairing` How a `quarantine.coverage` record's dm-verity lower device was paired to its model image during the host-VMI walk. Present in the record's `verity_device_pairing` field. The v1 registered values are: ``` verity-exact-root-digest verity-heuristic-size ``` `verity-exact-root-digest` is the strong pairing (the enforced dm-verity root hash recovered from guest memory matched the model image's `veritysetup` digest exactly); `verity-heuristic-size` is the weaker size-based heuristic used when the exact root digest could not be recovered. A verifier MUST reject any present `verity_device_pairing` value not in this list. ### `auth_downgrade_cause` The composition-safety violation reason an offline mesh evaluator records in an [a2a-mesh-scorecard](../v2/a2a-mesh-scorecard.md)'s `verdict.causes`. These are the **trace-to-auth-downgrade invariant** cause set: the specific ways authority can fail to attenuate as it flows hop-to-hop through an A2A mesh. The v1 registered values are: ``` IDENTITY_MISMATCH TEMPORAL DOWNGRADE SCOPE_BROADENING TOKENLESS_BREAK ``` A verifier MUST reject any `auth_downgrade_cause` value not in this list. The per-cause meaning is documented in [a2a-mesh-scorecard.md](../v2/a2a-mesh-scorecard.md). A renamed or removed cause is a breaking change (new major version); an added cause is a registry revision. ## How consumers use the registry - A [catch-record](catch-record.md) MUST carry `layer` and `violation_type` values drawn from this registry, and MAY carry a `sink` value drawn from this registry. - A producer MUST map any internal or per-runtime naming onto these exact registered strings before emitting a record. Equality comparisons in a verdict (for example, matching an actual layer to an expected layer) are decided over these normalized strings. - A verifier validates every enum value in every catch record it processes against the registry version it implements, and rejects the record (and any verdict that depends on it) on any unregistered value. - An [a2a-mesh-scorecard](../v2/a2a-mesh-scorecard.md) MUST carry `verdict.causes` values drawn from the `auth_downgrade_cause` list, and a verifier MUST reject a scorecard carrying an unregistered cause. ## Related standards - **[SARIF](https://sarifweb.azurewebsites.net/)** - SARIF defines its own taxonomies and rule identifiers. This registry is independent of SARIF's taxonomy; per-finding `check_id` values in layer-carrying verdicts are the SARIF crosswalk point, not these layer / violation / sink vocabularies. (Layer findings are out of the [adversarial-execution-evidence](adversarial-execution-evidence.md) core; its `layer` / `violationType` values ARE gated by this registry.) - **[IANA registries](https://www.iana.org/protocols)** - the governance model (a maintained, versioned, publicly-reviewed list of allowed values) follows the spirit of an IANA-style registry, scaled to this standard. ## Versioning This is the v1 registry. Any addition is a registry revision published as a new list; any rename or removal is a breaking change requiring a new major version. A v1 verifier evaluates values strictly against the v1 lists above and fails closed on anything else. ============================================================================== # DOCUMENT: v1/evidence-scope-disclosure # CANONICAL: https://getprobity.dev/predicate/v1/evidence-scope-disclosure # MARKDOWN: https://getprobity.dev/predicate/v1/evidence-scope-disclosure.md ============================================================================== # Probity Evidence-Scope Disclosure, v1 **predicateType:** `https://getprobity.dev/predicate/v1/evidence-scope-disclosure` An evidence-scope disclosure is a signed statement about a **published piece of evidence**: which layers of the detection stack it covers, which tier it sits at, whether it is a real capture or a representative fixture standing in for one, and - in prose the producer writes against its own interest - what it deliberately does not show. Every other predicate in this standard says something about a *subject*. This one says something about the *evidence*, and its whole job is to travel the limits alongside the claim so a reader cannot mistake a demonstration for a measurement. It is not a verdict. It carries no PASS / DEGRADED / FAIL, no per-attack result, and no containment chain. A [security-verdict](security-verdict.md) or an [adversarial-execution-evidence](adversarial-execution-evidence.md) statement carries an outcome; a disclosure carries the boundary around one. The two compose: a verdict says what happened, a disclosure says how far what happened generalizes. This predicate rides the DSSE / in-toto envelope specified in [signing-envelope.md](signing-envelope.md) without deviation. Read that document first; everything below concerns the predicate body and the checks a verifier performs *after* the envelope verifies. The key words MUST, MUST NOT, REQUIRED, SHOULD, SHOULD NOT, MAY, and OPTIONAL are to be interpreted as in [RFC 2119](https://www.rfc-editor.org/rfc/rfc2119). ## Why this type exists The disclosure body was originally emitted under the [signed-evidence](signed-evidence.md) predicate type, whose specification defines a completely different structure (`merkle_evidence`, `value_binding`, `fold_pins`). Two structurally unrelated bodies shared one type URI, and both verified only because every shipped verifier gates on the `predicateType` string and on envelope integrity, never on the body. A reader who dereferenced the type URI - the entire reason the type is a URL - got a document describing fields the statement did not carry. This type exists so the URI resolves to the shape the bytes actually have. There is no alias. `https://getprobity.dev/predicate/v1/signed-evidence` is not, and never was, a valid type for a disclosure body, and a producer MUST NOT emit one under it. ## Subject The subject is the artifact the evidence is about, by digest. ```json "subject": [{"name": "", "digest": {"sha256": "<64-hex>"}}] ``` The digest is 64 lowercase hexadecimal characters with no `sha256:` prefix. The subject of a disclosure is whatever the evidence was gathered *from* - a scanned agent image, a pinned corpus manifest - and a producer MUST bind a digest a third party can recompute from bytes it can obtain, never a name alone. ## Predicate body ```json { "evidence_type": "multi-server-benchmark", "tier": "static", "coverage_map": {"static": true, "runtime": false, "hosting": false}, "honest_scope": "", "is_synthetic_fixture": false, "reproduce": "research/68-bakeoff-corpus/run_all.sh", "benchmark": { "...": "the variant section selected by evidence_type" } } ``` The body has a REQUIRED **core**, an OPTIONAL `reproduce` pointer, and exactly one REQUIRED **variant section** named by `evidence_type`. Both the core and each variant section are closed: a producer MUST NOT emit a member not listed below, and the reference producer refuses to sign a section carrying one. ### Core | Field | Req | Type | Semantics | |---|---|---|---| | `evidence_type` | REQUIRED | string, closed enum | Which kind of evidence this is, and therefore which variant section the body carries. See the registry below. | | `tier` | REQUIRED | string, closed enum `static` \| `runtime` \| `live` | The deepest layer the evidence reaches. Mechanically determined by `coverage_map`; see the consistency rule below. | | `coverage_map` | REQUIRED | object, closed, three REQUIRED booleans | Which layers the evidence covers: `static`, `runtime`, `hosting`. At least one MUST be `true`. | | `honest_scope` | REQUIRED | non-empty string | The producer's prose statement of what the evidence does NOT show. Self-declared; see the observation table. | | `is_synthetic_fixture` | REQUIRED | boolean | `true` when the evidence is a really-signed REPRESENTATIVE of a shape rather than a capture of a run that happened. Self-declared; see the observation table. | | `reproduce` | OPTIONAL | non-empty string | A repository-relative path to the recipe that regenerates the evidence. A pointer, not a guarantee: see What this predicate does NOT assert. | **The `coverage_map` / `tier` consistency rule.** `tier` MUST equal the deepest layer set `true` in `coverage_map`, under the ordering `static < runtime < live`, where `hosting: true` maps to `live` and `runtime: true` maps to `runtime`. A verifier MUST reject a statement whose `tier` disagrees with its `coverage_map`. This is the one field pair in the body a verifier can check for free, and it exists so a disclosure cannot claim a deeper tier than the coverage it admits to. **`coverage_map` is closed at exactly three members.** All three are REQUIRED and all three are booleans; a `false` is a positive statement that the layer is not covered, never an omission. A producer MUST NOT signal "not covered" by leaving a member out, and a verifier MUST reject a `coverage_map` that is missing one. ### The `evidence_type` registry `evidence_type` is a CLOSED vocabulary. It selects the variant section, and every legal value has exactly one: | `evidence_type` | Variant section | What the variant carries | |---|---|---| | `live-attestation` | `live_attestation` | The hardware-attestation posture of a long-running, continuously observed agent | | `multi-server-benchmark` | `benchmark` | A head-to-head scanner comparison over a pinned multi-server corpus | A verifier MUST reject an `evidence_type` outside this registry, and MUST reject a statement whose body carries a variant section other than the one its `evidence_type` names (fail-closed; no warn tier, and no statement carrying two). Adding a value is a spec revision that publishes a new registry row together with the variant section's field table, never a silent additive change a deployed verifier is expected to tolerate. ### `live_attestation` REQUIRED when and only when `evidence_type == "live-attestation"`. | Field | Req | Type | Semantics | |---|---|---|---| | `integrity_tier` | REQUIRED | string, closed enum `roadmap` \| `attested` | `attested` means a hardware-rooted quote is carried in this statement. `roadmap` means the substrate primitives are built and the quote is not yet emitted. | | `tee_quote` | REQUIRED, nullable | base64 string or `null` | The hardware attestation quote. | | `ephemeral_pubkey` | REQUIRED, nullable | base64 string or `null` | The per-session public key bound into the quote. | | `observer_in_tcb` | REQUIRED | boolean | Whether the observing host is inside the attested trusted computing base. | **The integrity-tier consistency rule.** `integrity_tier == "roadmap"` if and only if BOTH `tee_quote` and `ephemeral_pubkey` are `null`. A verifier MUST reject any other combination. Without this rule a statement could declare `attested` and carry nothing, which is precisely the confusion the field exists to prevent. A verifier MUST NOT read `integrity_tier == "attested"` as a verified quote. The rule above is a structural check on the statement, not a validation of the quote's chain; validating a quote requires the vendor's endorsement material, which a disclosure does not carry. ### `benchmark` REQUIRED when and only when `evidence_type == "multi-server-benchmark"`. | Field | Req | Type | Semantics | |---|---|---|---| | `name` | REQUIRED | non-empty string | What was compared against what, in one line. | | `corpus` | REQUIRED | object, closed | The composition of the pinned input corpus, by count. | | `metadata_layer_scanners` | REQUIRED | object, non-empty | One tally per scanner run at the metadata layer, keyed by scanner id. | | `self_authored_vuln_server_tool_depth` | REQUIRED | object, non-empty | Per-scanner tool-depth result on the self-authored vulnerable server, keyed by scanner id. | | `source_code_layer_semgrep` | REQUIRED | object, closed | The source-code-layer scoreboard, kept separate from the metadata layer. | `corpus` carries exactly `servers`, `third_party`, `self_authored`, `malicious` and `benign_controls`, each a non-negative integer. `third_party + self_authored` and `malicious + benign_controls` MUST each equal `servers`: a corpus that does not partition twice has lost a row somewhere between the manifest and the statement. Each value of `metadata_layer_scanners` carries exactly `malicious_servers`, `malicious_servers_caught`, `benign_controls`, `benign_tools_total`, `benign_tool_false_positives` and `malicious_tool_attack_findings`, each a non-negative integer, with `malicious_servers_caught <= malicious_servers` and `benign_tool_false_positives <= benign_tools_total`. Each value of `self_authored_vuln_server_tool_depth` is either `null` (that scanner produced no row for the server) or an object carrying exactly `caught_tools` and `total_tools`, non-negative integers with `caught_tools <= total_tools`. The key set of `self_authored_vuln_server_tool_depth` MUST equal the key set of `metadata_layer_scanners`: a scanner present in one and absent from the other is a comparison with a hole in it. `source_code_layer_semgrep` carries exactly `malicious_caught` and `benign_false_positives`, each a `"/"` string of two non-negative integers, and `note`, a non-empty string. The source layer is a separate scoreboard and MUST NOT be summed with the metadata layer; the `note` says so in the statement so the separation travels with the numbers. ## Observed from outside versus self-declared (load-bearing) This is the section a reader of a disclosure must not skip. The members of this predicate fall into three classes, and confusing the third for the first is the exact failure this type is shaped to prevent. **Class 1 - checkable from the statement and the trust anchor alone.** The DSSE signature, the `keyid` binding, the subject digest binding, the closed member sets and value domains above, the `coverage_map` / `tier` consistency rule, the `integrity_tier` consistency rule, the corpus partition identities, and the scanner key-set equality. A verifier needs nothing but the bytes and a configured public key. **Class 2 - producer measurements, recomputable only against inputs the auditor supplies.** Every integer under `benchmark`. These were produced by running the named scanners over the pinned corpus. An auditor holding the same corpus and the same scanner versions can re-derive them - that is what `reproduce` points at - but the statement does not let anyone re-derive them from itself. A re-derivation that disagrees is a real finding; the absence of one is not a confirmation. **Class 3 - self-declared, and not a measurement of anything.** | Member | Why it is self-declared | |---|---| | `honest_scope` | Prose. Nothing observes it, nothing constrains it, and no verifier can confirm it is complete. It is the producer stating limits against its own interest, which is why it is worth carrying and exactly why it MUST NOT be read as a finding. | | `is_synthetic_fixture` | A producer asserting whether its own artifact stands for a run or captures one. A `false` here is a claim, not evidence; nothing in the statement distinguishes a real capture from a well-formed fixture that says it is real. | | `evidence_type`, `tier`, `coverage_map` | The producer's own account of what it covered. The consistency rule ties them to each other, never to the world: a producer that ran nothing can emit an internally consistent map. | | every member of `live_attestation` | Including `observer_in_tcb` and `integrity_tier`. The consistency rule is structural. No member of this section is grounded by anything in the statement. | | `reproduce` | A path. The statement does not commit to the recipe's contents by digest, so it is a pointer to where a reader should go, never a promise of what is there. | A consumer building an automated decision on a disclosure MUST treat class 3 as untrusted input from the party being described. In particular, a policy that admits an artifact because `is_synthetic_fixture == false` has delegated its decision to the producer. ## Producer validation The reference producer validates the core and the selected variant section against the tables above - closed member sets, the enum domains, non-empty strings where REQUIRED, non-negative integers, the `coverage_map` / `tier` rule, the `integrity_tier` rule, the corpus partitions, the scanner key-set equality, and a 64-lowercase-hex subject digest - and raises rather than signing a malformed predicate. This is the standard's producer-strict rule: a producer that cannot vouch for a member refuses to sign it. Every number a producer emits under `benchmark` MUST be derived from the run artifacts, never typed by hand. The two reference generators do this: the corpus disclosure derives its counts from `matrix.json` and its subject digest from the bytes of `corpus/manifest.jsonl`, and the live disclosure derives its body from the previously signed statement after verifying that statement's signature. The validated body is serialized under **RFC 8785 (JSON Canonicalization Scheme)** to produce the exact payload bytes the DSSE signature commits to, the same canonicalization every other predicate in this standard uses. ## Verification (third-party, offline) The envelope-level procedure is [signing-envelope.md](signing-envelope.md) §Verification procedure and is not restated here. In summary: confirm `payloadType == application/vnd.in-toto+json`, decode `payload` from base64 and keep the bytes **verbatim** (never re-serialize), resolve each signature's REQUIRED `keyid` against the configured trust anchor, and verify the raw ed25519 signature over `PAE("application/vnd.in-toto+json", )`. Two properties of the trust model are load-bearing here and a verifier MUST enforce both. - **A signing key is authorized per predicate type.** A published verification key carries the set of predicate types it may sign, and a verifier MUST reject a statement whose `predicateType` is not in the resolving key's authorized set. `https://getprobity.dev/predicate/v1/evidence-scope-disclosure` is in the authorized set of the producer's evidence-signing key (keyid `427a1092...`, published at `/.well-known/verification-keys.json`). - **Transparency-log upload is deliberately disabled, and a verifier MUST NOT require one.** Signing publishes nothing to any log, on purpose: the evidence stays exactly as private as the artifact it describes. A verifier MUST NOT treat the absence of a log entry as a defect and MUST NOT be built to require one. Once the envelope verifies, a verifier performs the predicate-specific checks: 1. Confirm `predicateType` is exactly `https://getprobity.dev/predicate/v1/evidence-scope-disclosure` and that the resolving key is authorized for it. 2. Check the core against its field table: closed member set, `evidence_type` in the registry, `tier` in its enum, `coverage_map` closed with three booleans and at least one `true`, non-empty `honest_scope`, boolean `is_synthetic_fixture`. 3. Confirm `tier` equals the deepest `true` layer of `coverage_map`. 4. Confirm the body carries exactly the variant section its `evidence_type` names, and check that section against its field table - including the `integrity_tier` rule for `live_attestation`, and the partition identities, bounds and key-set equality for `benchmark`. 5. Treat every class-3 member above as a producer assertion. There is no step that verifies one. 6. OPTIONALLY re-run the recipe at `reproduce` against the subject the statement binds, and compare the class-2 integers. No live VM, no hypervisor access, and no network access are required at any step. Step 6 is checkable only against inputs the auditor brings; see the honesty section below. ## What this predicate does NOT assert (coverage) - **A verdict.** No PASS / DEGRADED / FAIL, and none may be inferred. Outcomes live in [security-verdict](security-verdict.md) and [adversarial-execution-evidence](adversarial-execution-evidence.md). - **That the evidence was captured from a real run.** The signature establishes that the producer committed to these values, not that anything executed. `is_synthetic_fixture == false` is the producer saying so, and is exactly as trustworthy as the producer. - **That `honest_scope` is complete.** It states limits the producer chose to state. Nothing enumerates the ones it did not. - **That the recipe at `reproduce` exists, runs, or produces these numbers.** The path is not digest-committed. A consumer that needs the recipe pinned obtains its digest out of band. - **Anything about the substrate.** The byte-identity, fold-correctness and clone-uniqueness families are [substrate-integrity](substrate-integrity.md)'s, and a disclosure's `coverage_map` says nothing about them. - **A validated hardware quote.** `integrity_tier == "attested"` is a structural declaration; the quote's endorsement chain is out of scope and is not carried. - **Revocation.** Nothing in the statement expires. A consumer that needs revocation obtains it out of band. ## What a verifier can and cannot independently check (honesty) - A verifier CAN confirm the statement is authentic under a key it configured and authorized for this predicate type, that it binds exactly the subject digest it intended, and that the core and the selected variant section are well-formed and internally consistent under the closed contract. All of that is offline from the statement and the key alone. - A verifier CAN re-derive the `benchmark` integers **only when it supplies the external inputs** - the pinned corpus, the scanner builds, the recipe. That is a real check, but relative to anchors the auditor brings, never derivable from the statement. - A verifier CANNOT, from the statement alone or with any anchor, establish that `honest_scope` is complete, that `is_synthetic_fixture` is truthful, that the `coverage_map` reflects work that happened, or that any member of `live_attestation` describes a real machine. These are disclosures, and the type is named for what they are. ## Status and maturity - **Format, producer, and signing: implemented.** Two disclosures ship signed and verify offline: the multi-server corpus bake-off (`website/public/verify-bakeoff/`, generated by `research/68-bakeoff-corpus/sign_corpus_evidence.py`) and the always-on live-attestation shape (`website/public/verify-tier3/`, generated by `website/public/verify-tier3/sign_live_attestation_disclosure.py`). Both are regenerable; neither is hand-edited. - **Verifier recognition: implemented.** The predicate type is in the recognized set of the packaged TypeScript verifier (`packages/verify`), its website twin, the standalone Python verifier (`website/public/verify/probity-verify.py`) and the admission rego, and in the authorized-predicate set of the published evidence-signing key. - **Standardization status: wave 2.** The standard [README](README.md) lists this type as a wave-2 family. This document is normative for the type URI and is published so the URI resolves; wave-2 membership concerns the standardization track, not whether the format is stable. ## Related standards - **[signing-envelope.md](signing-envelope.md)** - the DSSE / in-toto envelope, `keyid` derivation, trust root, and offline-verify procedure this predicate uses without deviation. - **[signed-evidence](signed-evidence.md)** - a DIFFERENT type with a different body (`merkle_evidence`, `value_binding`, `fold_pins`). A disclosure is not a signed-evidence statement and MUST NOT be emitted under that type. - **[security-verdict](security-verdict.md)** and **[adversarial-execution-evidence](adversarial-execution-evidence.md)** - the outcome-bearing predicates a disclosure bounds rather than replaces. - **[in-toto attestation](https://github.com/in-toto/attestation)** - ALIGNED. The payload is an in-toto Statement v1; subject binding and `predicateType` routing are used as in-toto defines them. - **[DSSE](https://github.com/secure-systems-lab/dsse)** - ALIGNED, PAEv1 and ed25519, no deviation. - **[SLSA VSA](https://slsa.dev/spec/v1.0/verification_summary)** - RELATED, not overlapping. A VSA summarizes a policy evaluation's outcome; a disclosure carries no outcome and instead bounds one. ## Versioning This is the evidence-scope-disclosure v1 predicate. The core and every variant section are closed at the producer: a producer MUST NOT emit a member not defined above. Per the standard's [forward-compatibility rule](README.md), a verifier MUST ignore a member it does not recognize rather than rejecting the statement, and an additive optional member is a minor change that stays within v1. The `evidence_type`, `tier` and `integrity_tier` vocabularies are CLOSED registries and are fail-closed at this version: a verifier MUST reject a value it does not recognize. Adding a value - and, for `evidence_type`, the variant section it selects - is a spec revision that publishes a new registry row, never a silent additive change. A change to the core's meaning, to the `coverage_map` layer set, to the `evidence_type`-to-variant mapping, or to the consistency rules is breaking and ships as a new major-version URI. ============================================================================== # DOCUMENT: v1/compliance-crosswalk # CANONICAL: https://getprobity.dev/predicate/v1/compliance-crosswalk # MARKDOWN: https://getprobity.dev/predicate/v1/compliance-crosswalk.md ============================================================================== # Probity Compliance Crosswalk, v1 One signed run, ten lenses. Probity emits **one** Ed25519-signed evidence object per gate run — the `adversarial-execution-evidence` attestation with its `catch-record-v1` intercept records and its `sut-provenance`. This document is the crosswalk from that single evidence object to the controls of the compliance frameworks a regulated buyer must satisfy. The thesis is **one object, many lenses**, not ten bespoke integrations. Most frameworks do not require *different* evidence; they require the **same** underlying evidence — attributable, tamper-evident behavioral records plus a bill of materials — expressed in their own control language. OSCAL (NIST's machine-readable control format) is the crosswalk **spine**: NIST 800-53 is OSCAL-native and the other catalogs express into it, so the one signed evidence set reads out as ten OSCAL-crosswalked views. ## What Probity emits today (the honesty baseline) A conforming run emits, **today**, signed and offline-re-verifiable: - an `adversarial-execution-evidence` attestation (in-toto Statement, DSSE PAEv1, Ed25519) — the fused pass/degraded/fail `result` with per-attack `attackResults`, `containmentObserved`, and the `networkPosture` binding; - `catch-record-v1` intercept records — per-record-signed, RFC-6962 batch-rooted, tamper-evident evidence of each intercepted adversarial technique (surfaced as `interceptRecords`); - `sut-provenance` — the integrity-pinned, digest-bound dependency closure (`dep_closure_sha256`); - a CycloneDX 1.6 ML-BOM (dependency-closure component inventory) and SARIF v2.1.0 findings. It does **not** emit, today (these are roadmap and MUST NOT be claimed as present in a bundle): - an **OSCAL** assessment-results document — the crosswalk in this doc is the mapping; the OSCAL emitter is roadmap. We emit in-toto/DSSE + SARIF now. - a per-finding **OWASP / SAFE-MCP / MITRE ATLAS taxonomy tag** inside any signed predicate. The behavioral catch-records *map onto* these taxonomies (see below); the tag is a roadmap field. Copy may say "maps onto", never "already carries its ID". - a notified-body **EU AI Act** certificate, or per-framework "framework-shaped" artifacts. One signed object crosswalks to the frameworks; framework evidence **packs** are roadmap. - the execution-grounded **model-weight** half of the ML-BOM (the dependency-closure SBOM is built; the model-weight inventory is partial). The bright line for every consumer of this crosswalk: a verdict's **signature** is what re-verifies offline; the framework mapping is what this document asserts. Never imply a bundle *contains* an OSCAL package, a taxonomy tag, or a framework certificate — a visitor who re-verifies a real bundle would falsify it. ## The crosswalk `Status` legend: **emitted** = the artifact that satisfies the control is emitted, signed, and re-verifiable today; **partial** = the satisfying artifact is partially emitted; **pack** = the control mapping is real but ships as an aligned evidence pack (roadmap); **tag** = the evidence maps onto the control but the per-finding taxonomy field is roadmap. | # | Framework | Control(s) | Probity evidence | Status | |---|-----------|-----------|------------------|--------| | 1 | SOC 2 + AI overlay | TSC CC7.2, CC6.1, CC6.6 | signed evidence + interceptRecords (CC7.2 monitoring / audit-trail); per-run Firecracker microVM (CC6.1/CC6.6 logical-access + isolation boundary) | emitted | | 2 | ISO/IEC 27001:2022 | A.8.29 (security testing in dev & acceptance); A.8.8 (technical-vulnerability mgmt) | signed adversarial-execution-evidence + payload-bound catch-record chain = acceptance-time security-testing evidence | emitted | | 3 | EU AI Act Annex IV | Reg. 2024/1689 Art 11, Annex IV §2(g), Art 12 (logging) | interceptRecords + signed evidence (validation/testing logs, Art 12 event logging); AIBOM (§1 system/software description) | pack | | 4 | NIST 800-53 Rev 5 | AU-10, AU-3, AU-12, CM-8, SR-4 | Ed25519 signature (AU-10 non-repudiation); catch-records (AU-3 content + AU-12 generation); AIBOM (CM-8); sut-provenance (SR-4) | emitted | | 5 | NIST AI RMF 1.0 | MEASURE 2.7 (security & resilience evaluated and documented) | signed adversarial-test verdict + tamper-evident catch-records. (Does NOT occupy MANAGE / continuous post-deployment monitoring — the gate is point-in-time.) | emitted | | 6 | ISO/IEC 42001:2023 | Clause 8 (operational control), Annex A | signed evidence + interceptRecords + AIBOM = the operational-control audit records an AIMS audit consumes | emitted | | 7 | OWASP GenAI Top 10 (LLM / Agentic / MCP) | LLM06 Excessive Agency; LLM02 Sensitive Information Disclosure; LLM03 Supply Chain | catch-records = execution-grounded behavioral-containment evidence (blocked undeclared egress, file-read EPERM, SSRF attempt, captured credential exfil). NOT LLM01 (no prompt-content inspection). | tag | | 8 | MITRE ATLAS | AML.T0051 (LLM Prompt Injection), AML.T0054 (Jailbreak) | catch-records map onto the intercepted technique | tag | | 9 | NIST SSDF SP 800-218 (+ 800-218A GenAI profile) | PW.8 (task PW.8.2 — test executable code for vulnerabilities); PS.3 (PS.3.2 — release provenance) | signed gate verdict = the dynamic test report; sut-provenance = release provenance | emitted | | 10 | CycloneDX 1.6 ML-BOM (ECMA-424) | AI component inventory | AIBOM with first-class MCP-server / A2A-agent / tool components; sut-provenance dependency closure. Model-weight inventory partial. | partial | ## Status summary Six mappings are **fully built** — the artifact that satisfies the named control is emitted, signed, and offline-re-verifiable today with no new emitter: SOC 2, ISO 27001, NIST 800-53, NIST AI RMF, ISO 42001, NIST SSDF. Four are **architecturally-real mapping, emission/tag-roadmap**: EU AI Act (aligned pack, not a certificate), CycloneDX ML-BOM (dependency-closure built, model-weight partial), and OWASP GenAI + MITRE ATLAS (behavioral evidence maps onto the taxonomy; the per-finding tag is roadmap). Spanning all ten, the OSCAL assessment-results emitter is roadmap. ## Related - [`adversarial-execution-evidence`](adversarial-execution-evidence.md) — the signed evidence the crosswalk reads from. - [`catch-record`](catch-record.md) — the forensic-trace evidence. - [`sut-provenance`](sut-provenance.md) — the dependency-closure provenance. - [`ml-bom`](ml-bom.md) — the AI bill of materials. - [`README`](README.md) — the predicate standard index. ============================================================================== # DOCUMENT: v1/ml-bom # CANONICAL: https://getprobity.dev/predicate/v1/ml-bom # MARKDOWN: https://getprobity.dev/predicate/v1/ml-bom.md ============================================================================== # Probity ML-BOM (Execution-Grounded Model Bill-of-Materials), v1 **predicateType:** `https://getprobity.dev/predicate/v1/ml-bom` An ML-BOM is a signed, offline-verifiable **execution-grounded** bill of the model weights a frozen microVM actually loaded. Unlike a declared software BOM (which lists what an artifact is *supposed* to contain), this records what a guest process *demonstrably mapped into its address space* at the moment the VM was frozen - the ACCESS proof - and resolves each mapped region to a full-file identity through host-side sources - the IDENTITY proof. The distinguishing property is that **intent** comes from the guest (a VMA walk of what the process mmap'd) while **identity** comes from a host layer the guest cannot forge (a dm-verity manifest, an egress-proxy stream hash, or a host-block read), so a guest cannot misrepresent which weights it ran. An ML-BOM is consumed by the [a2a-mesh-scorecard](../v2/a2a-mesh-scorecard.md) as the per-member `model_bom` bound by digest. This document specifies the ML-BOM shape and the resolution rule; the signing envelope is in [signing-envelope.md](signing-envelope.md). The Go shape this mirrors is the reference implementation's `ModelBOM`, whose host-side identity sources are cited below. 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). ## Subject When an ML-BOM is the subject of its own attestation, the subject is the model surface it describes, bound by the BOM's canonical SHA-256 digest. More commonly the ML-BOM is referenced *by digest* from an [a2a-mesh-scorecard](../v2/a2a-mesh-scorecard.md) member entry (`model_bom_digest`), and the full BOM is served alongside, keyed by `member_id` and that digest. ```json "subject": [ { "name": "", "digest": { "sha256": "<64-hex-model-bom-digest>" } } ] ``` ## BOM shape ```json { "entries": [ { "pid": 101, "comm": "router-agent", "vm_start": 140737488355328, "vm_end": 140737505132544, "inode": 524289, "mapped_bytes": 16777216, "full_file_size": 16777216, "identity": { "source": "host-block", "algorithm": "sha256", "digest": "<64-hex full-file digest>", "full_size": 16777216, "note": "full-file sha256 from host path /var/lib/.../model.safetensors" } } ], "unresolved": [ { "region": { "pid": 202, "inode": 524290, "vm_start": 0, "vm_end": 0 }, "reason": "verity-catalog: (s_dev, inode) is not a declared model file; host-block: no host path for inode 524290" } ] } ``` ## Field semantics ### `entries` (REQUIRED, array) Each entry is one file-backed region a guest process had mmap'd (the ACCESS proof from a VMA walk), resolved to a full-file identity (the WHAT). Fields: - **Access proof (from the guest VMA walk):** - **`pid`** (REQUIRED, integer) - the guest process id that mapped the region. - **`comm`** (REQUIRED, string) - the process command name. - **`vm_start`** / **`vm_end`** (REQUIRED, integers) - the mapped virtual-address range. - **`inode`** (REQUIRED, integer) - the inode of the file backing the mapping. - **`mapped_bytes`** (REQUIRED, integer) - `vm_end - vm_start`: how much of the file was mapped into the address space. This is **NOT** how much was resident in RAM, and **NOT** the identity basis - see the partial-residency rule. - **`full_file_size`** (REQUIRED, integer) - the inode `i_size`: the size of the whole backing file, carried explicitly so a reader can contrast the full file against the mapped/resident bytes. - **Identity proof (from a host-side source), under `identity`:** - **`source`** (REQUIRED, string) - which host source resolved the identity and, for the verity path, the CONFIDENCE with which the guest device was paired to its signed lower (see the source rule). One of `verity-exact-root-digest`, `verity-heuristic-size`, `proxy-stream`, `host-block`. - **`algorithm`** (REQUIRED, string) - the digest algorithm (for example `sha256`). - **`digest`** (REQUIRED, 64-hex for sha256) - the model file's own **full-file** digest, lowercase hex. It is NEVER a hash of resident RAM, and NEVER the dm-verity root hash (the root hash identifies the LOWER and pairs the device; the identity digest is the per-file content hash). - **`full_size`** (OPTIONAL, integer) - the number of bytes the digest covers (the full file), or 0 if unknown. - **`note`** (OPTIONAL, string) - provenance detail (the model name, or the backing host path). - **`copied_up`** (OPTIONAL, bool) - true when the model was copied up to the writable upper and re-attributed by host-side hashing (a `host-block` source), bypassing the dm-verity block layer. A policy MUST treat a `copied_up` entry's `host-block` source as authoritative over any run-level verity pairing confidence: its bytes were host-hashed, not verity-enforced. ### `unresolved` (OPTIONAL, array) The file-backed model-candidate regions for which **no** host source could establish a full-file identity. Each entry carries the access proof (the `region`) and a `reason` aggregating why each source declined. This array is the fail-loud record the partial-residency rule requires: an unresolved region is recorded honestly, **never** silently dropped and **never** assigned a hash of resident RAM. An absent `unresolved` field (or an empty array) means every candidate region resolved. ## The host-resolution rule A mapped region's identity is established by trying host-side sources **in order**; the first source that resolves wins. The rule is satisfied if ANY host source speaks for the region. The sources, in the order the reference implementation's host-identity resolver tries them: 1. **`verity-exact-root-digest`** / **`verity-heuristic-size`** (image-baked catalog) - the inode is a DECLARED model file on a dm-verity workload lower. Its identity is the model file's per-file SHA-256, computed by the builder from the root-hash-pinned uncompressed EROFS bytes and looked up O(1) by the VMI-recovered `(s_dev, inode)` key (`PrecomputedVerityTable`). The two forms differ ONLY in how the guest device was paired to its signed contract lower: **`verity-exact-root-digest`** when the enforced dm-verity root hash recovered from guest RAM (the dm-mapper walk) exactly matched the signed lower's root hash (cryptographically unambiguous), **`verity-heuristic-size`** when that walk was unavailable and the device was paired by a bidirectional-unique data-region size (the honest-degrade fallback, which a policy may down-weight). **Status: live.** 2. **`proxy-stream`** (download-time) - the egress proxy computed a streaming SHA-256 of the model file as it was downloaded in transit; the BOM consumes that per-inode (or per-URL) streaming hash. **Status: interface seam only in v1** - the proxy lives on a separate boot/proxy effort; until that path is wired the proxy-stream source resolves nothing, and a region it would have covered falls through to the next source or to `unresolved`. 3. **`host-block`** (host-view read) - a full-file SHA-256 read from the host's own view of the backing store (block device / overlay upperdir), fully independent of what is resident in guest RAM. It is also the source for a model copied up to the writable upper and re-attributed by digest (which sets `copied_up` true), bypassing the dm-verity block layer. The inode-to-host-path mapping is environment-specific (an overlay scan, a `/proc//maps` cross-reference, or a known model-cache path). **Status: live.** A source that cannot speak to an inode returns "unresolved" and the resolver tries the next source. A source that hits a *real* read failure (for example a host-block read error) is surfaced LOUDLY as the reason - it is never masked into a silent unresolved. A region no source resolves goes to `unresolved`. ## INTENT-via-VMI, IDENTITY-via-host-layer (the grounding argument) The security property an ML-BOM provides, and the reason it is execution-grounded rather than declared: - **INTENT comes from the guest, via VM introspection (VMI).** Walking the guest VMAs tells you which files a process actually mapped - not what a manifest claims it would use. A model file present on disk but never mapped does not appear; a file mapped under an unexpected process does. The BOM reflects the run, not the declaration. - **IDENTITY comes from a host layer the guest cannot forge.** The full-file digest is established by a host-side source (a verity-anchored per-file digest, an egress-proxy stream hash, or a host-block read), none of which the guest can tamper with from inside its own address space. So the BOM records WHAT was run with an identity basis a compromised guest cannot rewrite. The two halves together defeat the failure mode of a declared BOM: a guest cannot claim to have loaded benign weights while running tampered ones, because the mapped inode's identity is read from outside the guest. ## Partial-residency rule (honesty) A mapped region is not necessarily fully resident in RAM, and the BOM **never** hashes resident RAM. The identity digest is always a **full-file** digest from a host source. This is why `mapped_bytes` and `full_file_size` are both carried and are explicitly distinguished from the digest: the digest covers the whole file (or the verity-covered image), regardless of how many pages were resident at freeze time. A region whose full-file identity no host source can establish is recorded in `unresolved` with the access proof intact - the BOM fails loud on an unresolvable region rather than substituting a partial or RAM-derived hash. ## Digest-alignment stability contract (training-provenance join) An entry's identity `digest` under `algorithm: sha256` is the SHA-256 of the **raw, uncompressed, full-file bytes** of the model weight file (a `.gguf` / `.bin` / `.safetensors`), taken in logical byte order. It is NOT a hash of an OCI layer tarball, NOT a hash of a compressed blob, and NOT a HuggingFace repo hash. This is the identical byte string an ML training or publishing pipeline hashes when it records `sha256(weight file)`, so the ML-BOM digest and the pipeline digest are equal by construction. (In the Go shape this is `ModelBOMEntry.Identity.Digest`.) For this equality to hold the weight file MUST be stored **uncompressed** in the substrate's EROFS verity lower: a compressed extent would make the on-image bytes differ from the raw file bytes and break the equality. The substrate builder already enforces this - the workload lowers are built without compression and the extent reader fails closed on any compressed extent - so a conforming producer never emits a `sha256` identity digest computed over compressed bytes. This equality is a **stability contract**, not an incidental implementation detail. It exists so a downstream training-provenance attestation - for example an [in-toto](https://github.com/in-toto/attestation) attestation whose subject is the weight file keyed by its raw-file SHA-256 - can JOIN to this execution evidence purely by digest equality: the ML-BOM states "process P mapped a file whose raw-file SHA-256 is D at freeze time", the provenance attestation states "the file with raw-file SHA-256 D was trained by pipeline L", and a consumer chains "an agent ran model M" to "model M's training lineage is L" with no shared naming and no live environment. Changing the digest basis (hashing an OCI layer, a compressed blob, a repo manifest, or a Merkle root instead of the raw file) silently breaks that join and is therefore a **breaking change** to this predicate, held to the same bar as any change to the full-file-only identity rule. The contract applies to EVERY entry carrying a full-file `sha256` identity digest - which is every verity-anchored entry (`verity-exact-root-digest` / `verity-heuristic-size`, whose identity digest is the builder's per-file SHA-256 of the raw uncompressed EROFS-lower bytes, NOT the lower's root hash) and every `host-block` entry. All are raw-file-SHA-256 join-ready. The dm-verity root hash pins the lower's integrity and pairs the device to its signed lower (the `verity-exact-`/`verity-heuristic-` distinction) but is never itself an entry's identity digest. ## Verification (third-party, offline) After the envelope checks in [signing-envelope.md](signing-envelope.md) succeed (when the ML-BOM is a standalone subject), or after resolving the BOM referenced by an [a2a-mesh-scorecard](../v2/a2a-mesh-scorecard.md) member's `model_bom_digest`: 1. Confirm the served BOM canonically hashes (SHA-256 over sorted-key, compact-separator JSON) to the bound `model_bom_digest`. 2. For each entry, confirm the identity `digest` is a full-file digest (its `algorithm` is declared and, for `host-block`, the `full_size` matches `full_file_size`), and that the `source` is one of the three registered sources. 3. Confirm every `unresolved` entry carries its access proof and a non-empty `reason` - an unresolved region MUST NOT be silently absent. 4. If the consumer has an auditor-approved set of model-file digests, compare each entry's identity digest against it (value-binding, as in the [security-verdict](security-verdict.md)). No live execution environment is required. ## What a verifier can and cannot independently check (honesty) - A verifier CAN confirm the BOM is authentic and bound to its subject digest, that every resolved entry carries a full-file identity from a declared host source, and that unresolved regions are recorded rather than dropped. - A verifier CANNOT, from the BOM alone, confirm that the VMA walk reflects the real guest address space, nor that a `host-block` digest was read from the genuine backing file. These are host self-assertions carried under the signature. The BOM's guarantee is integrity, provenance, subject binding, and the *structural* honesty rules (full-file-only identity, fail-loud unresolved) - not external re-execution of the VMI walk. ## Why no perimeter sandbox can emit it (rationale) A perimeter sandbox sees an application's API calls, not its address space. It cannot perform a VMA walk of what a guest process mmap'd (no VMI from outside the guest at the API layer), and it cannot resolve a mapped inode to a host-side full-file identity (no host view of the guest's backing store). The INTENT half requires VM introspection at the hypervisor boundary; the IDENTITY half requires a host layer below the guest. A competitor can copy the BOM JSON; it cannot fill an entry's access proof or a host-sourced full-file identity without the hypervisor-boundary substrate. ## Status and maturity - **Format and the BOM shape: stable, real.** The `entries` / `unresolved` structure, the host-side resolution rule, the full-file-only identity rule, and the partial-residency honesty rule are implemented in the reference implementation's Go introspection layer. - **Identity sources: verity catalog and host-block live, proxy a seam.** The verity-anchored catalog (`verity-exact-root-digest` / `verity-heuristic-size`) and `host-block` are live resolvers; `proxy-stream` is an interface seam only in v1 (the egress-proxy path is a separate effort) and resolves nothing until wired. - **VMI heap/region extraction maturity: partial.** The VMA-walk access-proof half is groundwork; the fail-loud `unresolved` recording is the honest disclosure for regions the current extraction cannot resolve. A live ML-BOM from an N>=2 jailed mesh run is bound by an [a2a-mesh-scorecard](../v2/a2a-mesh-scorecard.md); the shipped scorecard now binds a real N=2 jailed run whose members carry an honestly-empty ModelBOM (`model_bom.entries == null` — the bench guests are static-Go init images with no model-weight regions), not synthetic illustrative entries. ## Related standards - **[CycloneDX](https://cyclonedx.org/)** - RELATED, not overlapping. CycloneDX describes what an artifact *declares* it contains (including its ML-BOM extension). This predicate describes what a frozen VM *demonstrably mapped and ran*, with identity grounded in a host layer. A CycloneDX BOM and an execution-grounded ML-BOM are complementary: the former is the declaration, the latter is the execution-time evidence. - **[a2a-mesh-scorecard](../v2/a2a-mesh-scorecard.md)** - the consumer that binds a per-member ML-BOM by digest. - **[in-toto](https://github.com/in-toto/attestation) / [DSSE](https://github.com/secure-systems-lab/dsse)** - the envelope; see [signing-envelope.md](signing-envelope.md). ## Versioning This is the ml-bom v1 predicate. Adding a new optional field - including an additive identity-source kind or an additive per-entry field - is additive and stays within v1; a verifier ignores fields it does not recognize. A rename or removal of a field, a change to the identity-resolution rule, or a change to the full-file-only identity rule is breaking and requires a new major version. ## Changelog - **2026-07-19** - ModelIdentity wire keys changed from PascalCase to snake_case (including `CopiedUp` -> `copied_up`) in the reference producer's host-identity resolver, changing the ModelBOM / EnrichmentDigest JCS digest basis. Pre-release and box-gated; no committed golden pinned the old shape. ============================================================================== # DOCUMENT: v1/sut-provenance # CANONICAL: https://getprobity.dev/predicate/v1/sut-provenance # MARKDOWN: https://getprobity.dev/predicate/v1/sut-provenance.md ============================================================================== # Probity SUT-Provenance, v1 **predicateType:** `https://getprobity.dev/predicate/v1/sut-provenance` A sut-provenance attestation is a signed, offline-verifiable statement that a scanned System-Under-Test (SUT) image was built from a known base image and a **fully integrity-pinned dependency closure**. It binds the deployable SUT image (by OCI digest) to the exact, reproducible set of dependencies that went into it, so a verdict produced against that image can be tied to a verifiable build input rather than an opaque, unpinned one. This document specifies the predicate body. The signing envelope, subject binding, and offline-verify mechanics are in [signing-envelope.md](signing-envelope.md). The wire shape is additionally pinned by the JSON Schema SSOT at `spec/schemas/v1/sut-provenance-v1.schema.json`. 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). ## Subject The subject is the **built SUT image, by OCI digest**, scoped by the catalog server name: ```json "subject": [ { "name": "probity-sut/", "digest": { "sha256": "<64-hex-oci-digest>" } } ] ``` ## Predicate ```json { "sut_provenance_proof": { "proof_type": "sut-provenance-v1", "server_name": "", "packaging": "npm", "base_image_digest": "<64-hex>", "base_image_ref": "node:20-bookworm-slim", "builder": { "tool": "catalog/build_suts.py", "commit": "<7-to-40-hex git sha>", "vendor_command": "npm ci" }, "resolved_dependencies": [ { "uri": "pkg:npm/left-pad@1.3.0", "digest": { "sha512": "<128-hex>" } } ], "dep_closure_sha256": "<64-hex>", "dep_count": 1 } } ``` ## Field semantics - **`proof_type`** (REQUIRED) - the discriminator `sut-provenance-v1`. A verifier MUST reject an artifact whose `proof_type` it does not recognize; it severs this proof family from every other. - **`server_name`** (REQUIRED) - the catalog server name the image was built for. It scopes the subject (`probity-sut/`). - **`packaging`** (REQUIRED) - the build ecosystem, one of `npm` or `pip`. Fixes which digest algorithm the dependency closure uses (npm Subresource-Integrity converts to `sha512`; a pip `--require-hashes` lock pins `sha256`). - **`base_image_digest`** (REQUIRED) - the SHA-256 (64-char lowercase hex) of the base image the SUT was built `FROM`. **Honesty:** this pins WHICH base image was used; it is best-effort provenance metadata and is NOT the load-bearing guarantee (the dependency closure is). A build host that cannot resolve the base image digest omits the whole provenance emission rather than fabricate one. - **`base_image_ref`** (REQUIRED) - the human-readable base image reference (e.g. `node:20-bookworm-slim`). - **`builder`** (REQUIRED) - the build provenance: `tool` (the builder identity), `commit` (the builder's git commit, 7-to-40-char lowercase hex), and `vendor_command` (the integrity-verifying install command). **Honesty:** `builder.commit` is an auditor-pinnable identity of the build tree, not a recipe a third party can re-run to reproduce the bytes. - **`resolved_dependencies`** (REQUIRED) - the **fully digest-pinned dependency closure**, expressed as an array of [SLSA / in-toto ResourceDescriptors](https://slsa.dev/spec/v1.0/provenance): one entry per resolved package, each carrying a `uri` (a [Package-URL](https://github.com/package-url/purl-spec) - `pkg:npm/@` or `pkg:pypi/@`; the npm scope `@` prefix is percent-encoded to `%40`, e.g. `pkg:npm/%40scope/pkg@1.2.3`) and a `digest` map (`{: }`, with `alg` in `{sha512, sha256}` and the hex of the algorithm's width - sha512=128, sha256=64). The array MUST be non-empty and EVERY entry MUST carry a non-empty hex digest - an unpinned dependency cannot produce a conformant predicate (fail-closed). The producer harvests this from a lockfile (an npm `package-lock.json` or a uv `--generate-hashes` requirements lock) and installs with the ecosystem's integrity-verifying installer, so the closure is the exact, reproducible input set. npm Subresource-Integrity (`sha512-`) is converted to the ResourceDescriptor's lowercase-hex digest; a pip `--require-hashes` sha256 is already hex. - **`dep_closure_sha256`** (REQUIRED) - the SHA-256 (64-char lowercase hex) that binds the closure. It is computed deterministically: the `resolved_dependencies` array is SORTED by `uri` (the stable per-package key) and each entry serialised with sorted keys and compact separators before hashing. This determinism is a SEMANTIC constraint a JSON Schema cannot express; the producer and verifier both enforce it. A payload whose `dep_closure_sha256` does not re-derive from its carried `resolved_dependencies` is rejected. (The field name is retained from v1: it is the dependency-closure hash, now over `resolved_dependencies`.) - **`dep_count`** (REQUIRED) - the number of entries in `resolved_dependencies`. MUST equal `len(resolved_dependencies)`. ## Verification (third-party, offline) After the envelope checks in [signing-envelope.md](signing-envelope.md) succeed (signature, `keyid`, `predicateType`, subject non-empty): 1. Confirm the `subject` digest equals the SUT image the verifier intends to reason about. 2. Confirm `proof_type == "sut-provenance-v1"`, `packaging` is in `{npm, pip}`, and `base_image_digest` is a 64-char lowercase-hex sha256. 3. Confirm `resolved_dependencies` is non-empty, EVERY entry's `uri` is a `pkg:` purl, and EVERY entry carries a non-empty hex `digest`. 4. Re-derive `dep_closure_sha256` from the carried `resolved_dependencies` (sort by `uri`, then by the entry's full **RFC 8785 (JCS)** canonical bytes as a fully-discriminating tiebreak; the hash is the SHA-256 of the RFC 8785 canonical JSON of the sorted array) and confirm it equals the carried value, and that `dep_count == len(resolved_dependencies)`. No live execution environment is required. When a sut-provenance attestation accompanies an [adversarial-execution-evidence](adversarial-execution-evidence.md) attestation about the same SUT image, the verifier additionally confirms the two subjects carry the same digest. (The evidence predicate's core deliberately carries no provenance embed; a producer that embeds one does so via the evidence predicate's `_ext` extension convention, and the embedded copy's re-derived `dep_closure_sha256` MUST match the standalone attestation's.) ## Honesty A verifier confirms (a) the closure-hash binds the carried, fully-pinned closure, and (b) the subject binding ties the provenance to a specific SUT image. It does NOT confirm that the registry tarballs the integrity hashes pin are themselves benign, nor that the closure is the *complete* transitive set the maintainer intended - it proves the closure that WAS used is pinned and reproducible, not that pinned means safe. The base image digest is best-effort metadata; the load-bearing, mandatory guarantee is the integrity-pinned dependency closure. ## Related standards - **[SLSA Provenance](https://slsa.dev/spec/v1.0/provenance)** - a sut-provenance attestation plays a role analogous to SLSA build provenance (a signed statement of how an artifact was built), specialised to a pinned dependency closure with a deterministic binding hash. `resolved_dependencies` adopts the SLSA Provenance v1 `resolvedDependencies` shape (an array of in-toto ResourceDescriptors), so external SLSA tooling can parse the closure. `builder.commit` is modeled on the SLSA `builder.id` idea: an auditor-pinnable identity of the build tree. - **[in-toto](https://github.com/in-toto/attestation) / [DSSE](https://github.com/secure-systems-lab/dsse)** - the envelope; see [signing-envelope.md](signing-envelope.md). - **[adversarial-execution-evidence](adversarial-execution-evidence.md)** - the evidence attestation this provenance most commonly accompanies (subject-digest paired; optionally embedded via `_ext`). ## Versioning This is the sut-provenance v1 predicate. Adding a new optional field is additive and stays within v1; a verifier ignores fields it does not recognize and reads only the fields it cross-references. A change to the `dep_closure_sha256` derivation, a rename or removal of a field, or a change to the `resolved_dependencies` entry shape is breaking and requires a new major version. ============================================================================== # DOCUMENT: v1/signed-evidence # CANONICAL: https://getprobity.dev/predicate/v1/signed-evidence # MARKDOWN: https://getprobity.dev/predicate/v1/signed-evidence.md ============================================================================== # Probity Signed-Evidence Envelope, v1 **predicateType:** `https://getprobity.dev/predicate/v1/signed-evidence` A signed-evidence statement carries, under **one** signature, the three pieces of evidence a [security-verdict](security-verdict.md) references but does not itself contain: the guest-memory Merkle commitment for a scan, the value-binding that ties that commitment to a signed session and to the verdict subject, and the cross-host differential-snapshot **fold pins**. Before this predicate existed those three travelled separately and the fold pins travelled unsigned; folding them into one predicate lets an auditor check all three in a single offline verification instead of reconciling separate artifacts. It does not replace [substrate-integrity](substrate-integrity.md) (Attestation A) or [security-verdict](security-verdict.md) (Attestation B). It complements them by signing the evidence those attestations reference. This predicate rides the DSSE / in-toto envelope specified in [signing-envelope.md](signing-envelope.md) without deviation. Read that document first; everything below concerns the predicate body and the checks a verifier performs *after* the envelope verifies. The reference implementation is the Python `verdict_bundle` package: `build_signed_evidence_statement` and `emit_signed_evidence` (`_statements.py`, `_signing.py`), the `_MerkleEvidence` / `_ValueBinding` / `_FoldPin` contract models (`_models.py`), and `signed_evidence_sections_from_proof` (`_loaders.py`), which derives the `merkle_evidence` section from a validated byte-identity proof. 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). ## Subject The subject is the artifact the evidence is about, by digest - typically the customer MCP-server OCI image whose verdict the evidence binds to. ```json "subject": [{"name": "", "digest": {"sha256": "<64-hex>"}}] ``` The digest is 64 lowercase hexadecimal characters with no `sha256:` prefix. ## Predicate body ```json { "merkle_evidence": { "guest_memory_merkle_root": "", "leaf_size_bytes": 4096, "exclude_profile": ["virtio_used_ring"] }, "value_binding": { "session_manifest_digest": "", "verdict_subject_digest": "" }, "fold_pins": [ {"label": "base", "sha256": ""}, {"label": "diff", "sha256": ""} ] } ``` All three sections are REQUIRED. Each section is closed: a producer MUST NOT emit a member not listed below, and the reference producer refuses to sign a section carrying one. ### `merkle_evidence` The guest-memory Merkle commitment for the scan. | Field | Req | Type | Semantics | |---|---|---|---| | `guest_memory_merkle_root` | REQUIRED | string, 64 lowercase hex | The root of a per-page Merkle tree over the guest-memory image the workload resumed from. | | `leaf_size_bytes` | REQUIRED | positive integer | The Merkle leaf size in bytes (`4096` for 4 KiB pages). Pins the tree construction so a recomputed root is comparable. | | `exclude_profile` | REQUIRED | array of strings, MAY be empty | The device-IO region names excluded from the byte-equality claim. | - **`guest_memory_merkle_root`** is the **FOLDED** root - the commitment to the memory image the workload actually resumed from, not a fourth independently captured root. It is the `folded_merkle_root` of the paired [substrate-integrity](substrate-integrity.md) `byte_identity_proof`, carried here so this statement stands alone. The base and diff roots stay in that proof and are deliberately not duplicated: duplicating them would add bytes, not trust. Every page is hashed unmodified, including pages named by `exclude_profile`; the profile scopes the byte-EQUALITY claim between images, never the tree construction. A verifier recomputing this root MUST NOT mask before hashing. - **`exclude_profile`** draws from the same CLOSED vocabulary as [substrate-integrity](substrate-integrity.md)'s fold-correctness `exclude_profile`: `virtio_used_ring` is the only legal v1 entry. An entry outside it MUST be rejected (fail-closed; no warn tier). The names are public; the byte offsets behind them are not. An empty list means nothing was excluded - a pure byte-identity fold. ### `value_binding` Binds the Merkle evidence to the signed session and to the verdict it supports. | Field | Req | Type | Semantics | |---|---|---|---| | `session_manifest_digest` | REQUIRED | string, 64 lowercase hex | SHA-256 of the signed session manifest (`[session_id, substrate_digest, corpus_digest, gate_engine_commit]`). | | `verdict_subject_digest` | REQUIRED | string, 64 lowercase hex | SHA-256 of the paired [security-verdict](security-verdict.md) subject (the customer MCP OCI digest). | `session_manifest_digest` ties the evidence to the exact injection-time session, so the evidence cannot be re-pointed at a different run. `verdict_subject_digest` binds the evidence to the verdict it stands behind. ### `fold_pins` The cross-host differential-snapshot fold pins, signed here so they are no longer carried unsigned. | Field | Req | Type | Semantics | |---|---|---|---| | `label` | REQUIRED | non-empty string | A free-text identifier for the pinned value (for example `base`, `diff`, `folded`). | | `sha256` | REQUIRED | string, 64 lowercase hex | The pinned digest. | `fold_pins` MUST be a non-empty array, and its `label` values MUST be unique across the array: a duplicate label makes the pin set ambiguous and MUST be rejected. Each entry commits one input or output of the fold, so a verifier can confirm the fold's pinned values were the ones signed. ## Producer validation The reference producer validates every section against the contract models above (each closed to exactly the members listed, non-empty `fold_pins` with unique labels, a 64-lowercase-hex subject digest, `exclude_profile` drawn from the closed vocabulary, `leaf_size_bytes` a positive integer) and raises rather than signing a malformed predicate. This is the standard's producer-strict rule: a producer that cannot vouch for a member refuses to sign it. The validated sections are re-emitted as plain data and serialized under **RFC 8785 (JSON Canonicalization Scheme)** - the same canonicalization the Go side applies - to produce the exact payload bytes the DSSE signature commits to. Earlier revisions of this document described the serialization as "JCS-like (sorted keys, compact separators)". That description was wrong in a way that matters: RFC 8785 sorts member names by UTF-16 code unit and pins number serialization, and an independent producer implementing the looser description would emit different bytes for the same predicate and its signature would not verify. RFC 8785 is normative. ## Verification (third-party, offline) The envelope-level procedure is [signing-envelope.md](signing-envelope.md) §Verification procedure and is not restated here. In summary: confirm `payloadType == application/vnd.in-toto+json`, decode `payload` from base64 and keep the bytes **verbatim** (never re-serialize), resolve each signature's REQUIRED `keyid` against the configured trust anchor, and verify the raw ed25519 signature over `PAE("application/vnd.in-toto+json", )`. Two properties of the trust model are load-bearing here and a verifier MUST enforce both. - **A signing key is authorized per predicate type.** A published verification key carries the set of predicate types it may sign. A verifier MUST reject a statement whose `predicateType` is not in the resolving key's authorized set, so a key issued for trace records cannot mint an evidence statement even though both verify under ed25519. `https://getprobity.dev/predicate/v1/signed-evidence` is in the authorized set of the producer's evidence-signing key. - **There is no transparency log, and its absence is deliberate.** Signing does not upload to a public log, so there is no third-party inclusion proof, no independent timestamp, and no public record of the digests a statement binds. That is the point: the evidence stays exactly as private as the artifact it describes. A verifier MUST NOT treat the absence of a log entry as a defect, and MUST NOT be built to require one. An earlier revision of this document instructed a verifier to check "optional Rekor inclusion"; no such entry has ever existed for this predicate, and following that instruction would have led a verifier to conclude the artifact was unverifiable. Once the envelope verifies, a verifier performs the predicate-specific checks: 1. Confirm `predicateType` is exactly `https://getprobity.dev/predicate/v1/signed-evidence` and that the resolving key is authorized for it. 2. Check the three sections against the field tables above: closed member sets, digest forms, `leaf_size_bytes` positive, `exclude_profile` within the closed vocabulary, `fold_pins` non-empty with unique labels. 3. OPTIONALLY recompute `guest_memory_merkle_root` against the guest-memory image in hand and compare. Exclusions scope the equality claim, not the tree - do NOT mask before hashing. 4. Confirm `value_binding.session_manifest_digest` resolves to a signed session manifest the verifier holds, and that `value_binding.verdict_subject_digest` equals the subject of the paired [security-verdict](security-verdict.md). 5. Confirm the `fold_pins` match the fold inputs and outputs the verifier expects. No live VM, no hypervisor access, and no network access are required at any step. Steps 3 through 5 are checkable only against anchors the auditor brings; see the honesty section below. ## What this predicate does NOT assert (coverage) - **That the evidence was captured from a real run.** The signature establishes that the producer committed to these values, not that a workload executed. A verifier confirms integrity, subject binding, and internal consistency, never external re-execution. - **Fold correctness.** The pins commit values; the claim that the fold reconstructs exactly the bytes Firecracker serialized is the [substrate-integrity](substrate-integrity.md) `fold-correctness-v1` family's, and MUST NOT be inferred from a signed-evidence statement. - **Entropy or clone uniqueness.** Those are separate proof families under [substrate-integrity](substrate-integrity.md), each with its own discriminator. - **A verdict.** This predicate carries evidence, not an outcome. The PASS / DEGRADED / FAIL judgement lives in [security-verdict](security-verdict.md). - **Public discoverability or non-repudiation via a transparency log.** See Verification. - **Revocation.** Nothing in the statement expires. A consumer that needs revocation obtains it out of band. ## What a verifier can and cannot independently check (honesty) - A verifier CAN confirm the statement is authentic under a key it configured and authorized for this predicate type, that it binds exactly the subject digest it intended, and that all three sections are well-formed under the closed contract. All of that is offline from the statement and the key alone. - A verifier CAN recompute the Merkle root, resolve the session-manifest binding, and match the fold pins **only when it supplies the external references** - the guest-memory image, the session manifest, the paired verdict. These are real checks, but relative to anchors the auditor brings, never derivable from the statement alone. - A verifier CANNOT, from the statement alone, establish that the memory image was captured from the workload the subject names, or that the pinned fold values came from the fold they claim to. ## Status and maturity - **Format, producer, and signing: implemented.** The statement builder, the closed contract models, and the DSSE signing path exist and are exercised end to end by the chain harness in `scripts/e2e_step0.py`, which emits a signed-evidence bundle and offline-verifies it in the same run. - **Verifier recognition: implemented.** The predicate type is in the recognized set of both the TypeScript verifier (`packages/verify`) and the Python verifier (`website/public/verify/probity-verify.py`), and in the authorized-predicate set of the published evidence-signing key. Structural gates beyond envelope verification and predicate-type authorization are the producer's; a consumer that needs them applies the field tables above. - **Standardization status: wave 2.** The standard [README](README.md) lists signed-evidence as a wave-2 family. This document is normative for the type URI and is published so the URI resolves; wave-2 membership concerns the standardization track, not whether the format is stable. ## Related standards - **[signing-envelope.md](signing-envelope.md)** - the DSSE / in-toto envelope, `keyid` derivation, trust root, and offline-verify procedure this predicate uses without deviation. - **[substrate-integrity](substrate-integrity.md)** - Attestation A. Carries the three-root `byte_identity_proof` this predicate's folded root is drawn from, and the fold-correctness, clone-uniqueness, and VMGenID-reseed claims this predicate does not make. - **[security-verdict](security-verdict.md)** - Attestation B. Its subject is what `value_binding.verdict_subject_digest` binds. - **[in-toto attestation](https://github.com/in-toto/attestation)** - ALIGNED. The payload is an in-toto Statement v1; subject binding and `predicateType` routing are used as in-toto defines them. - **[DSSE](https://github.com/secure-systems-lab/dsse)** - ALIGNED, PAEv1 and ed25519, no deviation. - **[Sigstore / cosign](https://docs.sigstore.dev/)** - NOT USED. This predicate is a DSSE envelope signed with a static ed25519 key; there is no Fulcio certificate and no Rekor transparency-log entry. A DSSE-aware tool can verify it given the payload type and the public key. ## Versioning This is the signed-evidence v1 predicate. Its three sections are closed at the producer: a producer MUST NOT emit a member not defined above. Per the standard's [forward-compatibility rule](README.md), a verifier MUST ignore a member it does not recognize rather than rejecting the statement, and an additive optional member is a minor change that stays within v1. The `exclude_profile` vocabulary is a CLOSED registry shared with [substrate-integrity](substrate-integrity.md): `virtio_used_ring` is the only legal v1 entry and a verifier MUST reject any entry it does not recognize. Adding a name is a spec revision that ships a new published registry list, never a silent additive change a deployed verifier is expected to tolerate. A change to the Merkle construction, to the shape or meaning of any of the three sections, or to the binding semantics is breaking and ships as a new major-version URI. ============================================================================== # DOCUMENT: v1/substrate-integrity # CANONICAL: https://getprobity.dev/predicate/v1/substrate-integrity # MARKDOWN: https://getprobity.dev/predicate/v1/substrate-integrity.md ============================================================================== # Probity Substrate-Integrity Attestation, v1 **predicateType:** `https://getprobity.dev/predicate/v1/substrate-integrity` Attestation **A**: a signed, offline-verifiable statement about the isolation substrate customer scans run on, rather than about any one scan. One is emitted per substrate release, NOT per customer scan, and every [security-verdict](security-verdict.md) (Attestation B) references it by digest. This predicate rides the DSSE / in-toto envelope specified in [signing-envelope.md](signing-envelope.md) without deviation. Read that document first; everything below concerns the predicate body and the checks a verifier performs *after* the envelope verifies. The reference implementation is split across a Go half and a Python half. The Go producer and wire validators are its byte-identity package (the fold, clone, reseed, Merkle and oracle wire validators); the statement builder and the mirrored contract models are its verdict-bundle package (`build_substrate_integrity_statement`, `emit_substrate_integrity`, `emit_clone_uniqueness`, `emit_vmgenid_reseed`, and the `_ByteIdentityProof` / `_CloneUniquenessProof` / `_VmgenidReseedProof` models). The two validate the same gates so a producer and a consumer cannot diverge. 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 Cryptographically proves the substrate that runs customer scans is **deterministic and its differential-snapshot fold is byte-identity-verified**: the folded guest-memory image (CAS-assembled from base + diff) is byte-identical to an independent same-instant Firecracker capture, modulo a disclosed set of virtio used-ring device-IO regions. This is what makes every `security-verdict` (Attestation B) trustworthy: B references A by digest. forkd (same-host probes + md5-of-blobs) structurally cannot produce this. ## Acceptance bar (T0–T3 taxonomy) The claim this predicate makes is calibrated against a four-tier byte-identity acceptance-criteria taxonomy, reproduced here so a reader can place the claim without an external reference: | Tier | Bar | Who publishes it | Verification primitive | |---|---|---|---| | **T0** | Byte-identical, no exceptions | Reproducible Builds (artifacts) | crypto hash of full artifact | | **T1 (Probity product bar)** | **Masked-byte-identical** — bit-for-bit modulo a declared, closed, minimal nondeterminism mask | **Probity** (live demonstration evidence is tracked in the producer's internal evidence ledger, not in this specification) | hash-and-compare after masking; mask published as a closed set | | **T2** | Output-equivalent — same inputs ⇒ same observable outputs | differential testing (Csmith/EMI) | compare outputs / checksums | | **T3** | Functional-resume — continues correctly, no equality claim | CRIU, QEMU/GCE migration, Firecracker (CRC), forkd probes | liveness/probe/CRC | This predicate carries the **fold-correctness** half of the T1 bar: the fold engine reconstructs, bit-for-bit, the exact memory image Firecracker would dump at the same instant, with the only exclusions being the virtio used-ring pages the Firecracker device-emulation thread legitimately advances during a paused snapshot (Firecracker pauses vCPU threads, not device emulation). The **cross-resume entropy-uniqueness** half (the two-sided anti-Brooker assertion) is a DISTINCT claim carried by a separate future attestation — see "Severed claim" below. The two are never conflated: a verifier reading this predicate learns the fold is correct, and must NOT infer entropy-uniqueness from it. ## Subject The **FROZEN_SUT OCI digest** — the pinned substrate the proof was computed against (rootfs OCI image; the SDK commit is recorded in the predicate). This reuses the existing `FROZEN_SUT.lock.toml` release-gating pin; the substrate-integrity attestation is the cryptographic CONSUMER of that pin. ```json "subject": [{"name": "probity-frozen-sut", "digest": {"sha256": ""}}] ``` ## Predicate ```json { "byte_identity_proof": { "proof_type": "fold-correctness-v1", "base_merkle_root": "", "diff_merkle_root": "", "folded_merkle_root":"", "exclude_profile": ["virtio_used_ring"], "image_size_bytes": "", "exclude_coverage": { "virtio_used_ring": {"region_count": "", "excluded_bytes": ""} }, "exclude_derivation": { "vmstate_sha256": "" }, "oracle_agreement": true, "used_real_firecracker": true, "reference_oracle": "firecracker-snapshot-editor@v..", "sdk_commit": "" } } ``` Every member above is REQUIRED, and the member set is closed: the producer models narrow the body to exactly these keys and refuse to sign one carrying any other. An earlier revision of this document showed this block without `image_size_bytes`, `exclude_coverage`, or `exclude_derivation`. That example was not merely incomplete - a producer that emitted it verbatim would be REJECTED by both the Go wire validator and the Python contract model, because all three are required. ### Field semantics - **proof_type** — MUST be exactly `fold-correctness-v1`. The discriminator that severs this proof family from the clone-uniqueness, entropy-uniqueness and vmgenid-reseed families; a verifier MUST reject an artifact whose proof_type it does not recognize. - **base/diff/folded_merkle_root** — root of a per-page (4 KiB-leaf) SHA-256 Merkle tree over the respective guest-memory image, 64 lowercase hex chars each. `folded == fold(base, diff)`; verification = recompute the folded root and compare. O(log N) corrupt-page localization on mismatch. The `folded_merkle_root` is the value the paired [signed-evidence](signed-evidence.md) predicate carries as its `merkle_evidence.guest_memory_merkle_root`. - **exclude_profile** — the closed set of device-IO region types EXCLUDED from the byte-equality check. Each region's byte offsets are **sourced from provenance** (the virtqueue used-ring guest-physical addresses Firecracker recorded in the snapshot vmstate; gate = device activated AND queue ready), never from a per-run diff. `virtio_used_ring` is currently the only legal entry. The profile MAY be empty (a guest with no active virtqueues yields a pure byte-identity fold). Excluded regions carry NO uniqueness assertion: requiring a used ring to differ would bind the proof to a device-IO timing race. (Region-location method is trade-secret; the profile NAMES are public.) - **image_size_bytes** — byte size of the compared guest-memory images, so a reader can bound the excluded fraction of the image from the artifact alone. MUST be a non-zero multiple of 4096. - **exclude_coverage** — per-region byte counts (`region_count`, `excluded_bytes`) quantifying the excluded extent. Its keys MUST be exactly the region types `exclude_profile` names: a missing entry leaves a named exclusion unquantified, and an extra entry quantifies an exclusion the profile denies. `virtio_used_ring`'s `excluded_bytes` MUST be page-rounded (used rings are page-rounded by construction). The coverage total MUST NOT exceed `image_size_bytes`. Names alone would leave the excluded magnitude unbounded, which is what this member exists to prevent. - **exclude_derivation** — the exclude profile's provenance. For this family it carries `vmstate_sha256` (64 lowercase hex) and NOTHING else: the used-ring addresses are sourced from the snapshot vmstate, and a `snapshot_metadata_sha256` key MUST be rejected here. (The clone-uniqueness family's `exclude_derivation` differs — see that section.) - **oracle_agreement** — true iff the producer's fold and Firecracker `snapshot-editor edit-memory rebase` produced byte-equal merged images on the same `(base, diff)` inputs. Differential reference oracle (Csmith/EMI-style): proves AGREEMENT, not absolute correctness; `reference_oracle` pins the FC version. The producer refuses to assemble a proof on disagreement rather than recording `oracle_agreement: false`, so a conforming artifact never carries the value `false`. - **used_real_firecracker** — MUST be `true` for an attestable proof: the snapshots were produced by a real Firecracker VMM and the oracle comparison ran against the real `snapshot-editor` binary. An in-process-model run (simulated snapshots or a reimplemented fold model) is development-only and MUST NOT be signed into Attestation A. - **reference_oracle** — the string MUST match `firecracker-snapshot-editor@v..`, optionally followed by a `-` suffix. The `v`-prefixed semantic version is normative, not illustrative: the producer enforces exactly this shape. The release tag MUST be the PROBED `snapshot-editor --version` output of the binary actually executed (not an assumed or configured value) and MUST equal the `firecracker_version` that produced the snapshots being verified. - **sdk_commit** — 7–40 char lowercase-hex git commit of the producing SDK. ### The fold-correctness property (what this proof asserts) The folded image is byte-EQUAL to an INDEPENDENT same-instant Firecracker Full capture on the complement of the excluded regions. The witness is a reference capture taken at the same paused instant as the diff — NOT a second restore (a restore resumes the guest to agent-readiness, drifting hundreds of deterministic pages with no provenance-recoverable addresses; such a witness can never ground an honest equality claim). This is a CAS validation: it proves the differential engine and the fold reconstruct exactly the bytes Firecracker serialized, which is the property every downstream verdict's substrate reference depends on. ### Severed claim: cross-resume entropy uniqueness (NOT asserted here) The two-sided property — equality on the complement of a nondeterminism mask PLUS provable DIFFERENCE of entropy/VMGenID regions across successive resumes (the anti-snapshot-clone-entropy-reuse assertion, Brooker-class, arXiv:2102.12892) — is deliberately NOT part of this predicate. Overloading one proof family with both claims would let a verifier mistake "device-IO pages excluded" for "entropy proven unique". The SUBSTRATE half of that claim ships as its own proof family, `proof_type: "clone-uniqueness-v1"` — see the predicate section below: two zero-execution paused restores of the same snapshot are byte-equal on the complement of {the VMGenID region (masked, MUST differ), the kvmclock wall-clock struct and virtio used rings (excluded device-IO, no uniqueness assertion)} while the VMGenID 16 bytes provably DIFFER — the substrate mints fresh clone entropy per restore. The GUEST-consumption half (the guest CSPRNG actually reseeded FROM the restore-minted signal) ships as the `proof_type: "vmgenid-reseed-v1"` family — the SOUND host-side `base_crng` differential — see the predicate section below. That family SUPERSEDES the earlier `proof_type: "entropy-uniqueness-v1"` getrandom witness, which is retained as a documented-unsound predecessor (its userspace draws can diverge with zero VMGenID involvement); live-run evidence for the sound reseed family is tracked in the producer's internal evidence ledger, not in this specification. ### entropy-uniqueness-v1 proof family (guest CSPRNG consumption) Carries the GUEST-consumption half of the severed claim: across two resumed restores of the same snapshot, the guest's CSPRNG reseed actually drew on the restore-minted generation signal, so the witnessed entropy draws DIFFER. **Uniqueness-only semantics**: this family asserts only that the named regions differ between the two captures. There is NO complement-equality clause (the captures are resumed restores — the guest executed, so no byte-equality claim over the rest of the image is honest) and NO oracle fields (there is no fold to police and no reference merge to agree with). A verifier MUST NOT infer fold correctness or complement equality from this proof, and MUST NOT infer entropy uniqueness from any other family. ```json { "byte_identity_proof": { "proof_type": "entropy-uniqueness-v1", "image_a_merkle_root": "", "image_b_merkle_root": "", "mask_profile": ["vmgenid_16b", "csprng_entropy"], "vmgenid_gpa": "", "image_size_bytes": "", "mask_coverage": { "vmgenid_16b": {"region_count": 1, "excluded_bytes": 16}, "csprng_entropy": {"region_count": 1, "excluded_bytes": ""} }, "draw_a": {"gpa": "", "length_bytes": "", "sha256": "", "barrier_elapsed_ms": ""}, "draw_b": {"gpa": "", "length_bytes": "", "sha256": "", "barrier_elapsed_ms": ""}, "sdk_commit": "" } } ``` Field semantics and verifier gates (the producer validates the identical shape before any bytes hit disk): - **proof_type** — MUST be exactly `entropy-uniqueness-v1`. A verifier MUST reject any artifact whose proof_type it does not recognize. - **image_a_merkle_root / image_b_merkle_root** — per-page (4 KiB-leaf) SHA-256 Merkle roots of the two resumed-restore captures, 64 lowercase hex chars each. These are **capture commitments with NO equality semantics**. The two roots MUST differ: a passing uniqueness verdict requires the asserted-unique regions to differ, which forces a leaf — hence the root — to differ; equal roots are internally inconsistent with the claim and MUST be rejected. - **mask_profile** — the closed per-family asserted-unique region vocabulary. For v1 it MUST be exactly the ordered pair `["vmgenid_16b", "csprng_entropy"]`; any other length, entry, or order MUST be rejected. The closed per-family vocabulary keeps a verifier from ever inferring one family's claim from another's artifact. - **vmgenid_gpa** — the guest-physical address of the 16-byte generation-id region (the same architecture constant in both images). MUST be non-zero and MUST place a 16-byte region inside the image (`vmgenid_gpa + 16 <= image_size_bytes`). A verifier additionally asserts the value equals its OWN architecture constant, so the proof demonstrably checked the SAME region the verifier's constant names. - **image_size_bytes** — byte size of the compared capture images. MUST be a non-zero multiple of 4096. - **mask_coverage** — per-region byte counts (`region_count`, `excluded_bytes`) quantifying the asserted-unique extent from the artifact alone. MUST contain exactly the two vocabulary entries: `vmgenid_16b` MUST be exactly one 16-byte span (`region_count` 1, `excluded_bytes` 16), and `csprng_entropy` MUST be exactly one span whose `excluded_bytes` equals the carried draw length. - **draw_a / draw_b** — each capture's witness-draw provenance: `gpa` and `length_bytes` MUST be non-zero and place the region inside the image (overflow-checked); `sha256` is the digest the captured bytes were checked against, 64 lowercase hex chars; `barrier_elapsed_ms` is how long the reseed barrier waited (the jitter bound for the causal-attribution argument) and MUST be non-negative. The two draws MUST have equal `length_bytes` (uniqueness is defined over equal-length byte strings) and their `sha256` values MUST differ — identical draw digests are the snapshot-clone entropy-reuse failure this family exists to catch and MUST be rejected. - **sdk_commit** — 7–40 char lowercase-hex git commit of the producing SDK. ### vmgenid-reseed-v1 proof family (guest CSPRNG reseeded FROM the restore-minted VMGenID — the SOUND base_crng differential) Carries the SOUND form of the guest-consumption claim and SUPERSEDES the `entropy-uniqueness-v1` getrandom witness (which is unsound: a stale `base_crng.birth` timed reseed plus the per-extract RDSEED/RDRAND/RDTSC mix diverge the witnessed draws with ZERO VMGenID involvement, a false PASS — verified against linux-6.1.137 `drivers/char/random.c`). Instead of witnessing userspace draws, this family reads the kernel ROOT CSPRNG state (`base_crng.{generation, key}`) host-side via the VMI chain from FOUR paused captures of one snapshot S0, and proves the guest kernel CSPRNG reseeded FROM the restore-minted VMGenID. **Differential semantics with a DEDUCTIVE attribution**: the soundness rests not on a single negative control but on a kernel-lemma caller enumeration (below); the family asserts the four-leg base_crng relation and the genid change, and carries NO oracle fields and NO complement-equality clause. A verifier MUST NOT infer fold correctness, complement equality, or entropy uniqueness from this proof, and MUST NOT infer this claim from any other family. The four captures: **S0** — the pre-snapshot state after a forced `RNDRESEEDCRNG` (a fresh `base_crng.birth` + `crng_init == CRNG_READY`), carrying `(gen0, key0)` and VMGenID region value `vmgen_a`. **CONTROL** — S0 restored PAUSED and NEVER resumed (zero guest execution); its base_crng MUST still read `(gen0, key0)`. This is the apparatus-fidelity baseline (the restore + VMI-read left base_crng untouched at zero execution); it is NOT a genid-held resume (Firecracker always re-mints the VMGenID on restore, and holding it would require an untrusted host write into guest RAM). **TEST** — S0 restored + briefly resumed; the restore-minted fresh VMGenID drives `vmgenid_notify` → `add_vmfork_randomness` → `crng_reseed`, so base_crng reads `(gen0+1, key_test ≠ key0)`; its VMGenID region is `vmgen_b`. **TEST2** — a SECOND independent restore + brief resume; the second restore mints a DISTINCT fresh VMGenID, so its reseed produces a DISTINCT key `(gen0+1, key_test2 ≠ key_test ∧ ≠ key0)` — the anti-snapshot-clone-entropy-reuse corroboration (Brooker-class, arXiv:2102.12892): a deterministic non-genid reseed would produce the SAME key on both restores. **Why it is sound (the load-bearing DEDUCTIVE attribution):** `base_crng.{generation, key}` change ONLY in `crng_reseed` (random.c). Its callers in the sub-second measurement window are exhaustively: the TIMED/cold-start reseed — DEAD, because the pre-snapshot `RNDRESEEDCRNG` sets a fresh `base_crng.birth` (random.c:227) and the window « `CRNG_RESEED_INTERVAL` = 60·HZ = 60s (random.c:178-181); `add_vmfork_randomness` (random.c:947-955) — the vmfork, the only live reseed source on a brief resume; the `RNDRESEEDCRNG` ioctl — not invoked in the window. `add_interrupt_randomness` (random.c:1068-1092) only mixes the per-CPU fast_pool and NEVER calls `crng_reseed`. Therefore on a brief resume the SOLE reseed source is the vmfork, so the TEST/TEST2 reseeds are vmfork-caused; and the kernel lemma — `add_vmfork_randomness` calls `add_device_randomness` mixing the VMGenID into the input pool BEFORE `crng_reseed` extracts the new key (random.c:953-955) — proves each new key INCORPORATES that restore's distinct VMGenID. The zero-execution CONTROL is the apparatus baseline; this caller enumeration is the causation; TEST2 corroborates the per-restore genid dependence. ```json { "vmgenid_reseed_proof": { "proof_type": "vmgenid-reseed-v1", "s0_merkle_root": "", "control_merkle_root": "", "test_merkle_root": "", "test2_merkle_root": "", "gen0": "", "gen_control": "", "gen_test": "", "gen_test2": "", "key0_sha256": "", "key_control_sha256": "", "key_test_sha256": "", "key_test2_sha256": "", "vmgenid_a_sha256": "", "vmgenid_b_sha256": "", "base_crng_gpa": "", "image_size_bytes": "", "sdk_commit": "" } } ``` Field semantics and verifier gates (the producer validates the identical shape before any bytes hit disk — `pkg/byteident/reseedwire.go`): - **proof_type** — MUST be exactly `vmgenid-reseed-v1`. The discriminator that severs this family from the fold-correctness, clone-uniqueness, and entropy-uniqueness families; a verifier MUST reject any artifact whose proof_type it does not recognize. - **s0_merkle_root / control_merkle_root / test_merkle_root / test2_merkle_root** — per-page (4 KiB-leaf) SHA-256 Merkle roots of the four paused captures, 64 lowercase hex chars each. These are **capture commitments with NO equality semantics**. The two RESEEDED roots MUST each differ from S0 and from each other (`s0 ≠ test`, `s0 ≠ test2`, `test ≠ test2`): a reseed forces a base_crng leaf — hence the root — to differ, and distinct per-restore keys force the two test roots to differ; equal roots there are internally inconsistent with the claim and MUST be rejected. The CONTROL root is NOT constrained equal to S0: a zero-execution restore re-mints the VMGenID region and KVM re-stamps the kvmclock wall-clock struct, so the whole-image root MAY legitimately differ even though base_crng reads identical. - **gen0 / gen_control / gen_test / gen_test2** — the `base_crng.generation` counters read from the four captures (verbatim little-endian unsigned long). `gen_control` MUST equal `gen0` (the zero-execution baseline did not reseed); `gen_test` and `gen_test2` MUST each equal `gen0 + 1` (each resumed restore reseeded by exactly one generation — a jump of 2 would mean a second, non-vmfork reseed also fired and MUST be rejected). `gen0` MUST NOT be at the generation-counter wrap boundary (`gen0 + 1` would be the `ULONG_MAX` sentinel `crng_reseed` avoids, random.c:223-225). - **key0_sha256 / key_control_sha256 / key_test_sha256 / key_test2_sha256** — SHA-256 of each capture's `base_crng.key` (32 bytes), 64 lowercase hex chars. The raw key is NEVER carried — only its digest — so a signed proof leaks no live CSPRNG key material. `key_control_sha256` MUST equal `key0_sha256` (the zero-execution baseline did not change the key); `key_test_sha256` MUST differ from `key0_sha256` (the TEST reseed produced new key material); `key_test2_sha256` MUST differ from BOTH `key0_sha256` (it reseeded) AND `key_test_sha256` (the two restores' distinct VMGenIDs drove distinct keys — the anti-Brooker corroboration; equal test/test2 keys are the deterministic-reseed failure this leg exists to catch and MUST be rejected). - **vmgenid_a_sha256 / vmgenid_b_sha256** — SHA-256 of the 16-byte VMGenID region read from S0 (`vmgen_a`) and the TEST capture (`vmgen_b`). They MUST differ — a fresh VMGenID was actually injected for the TEST leg; equal digests mean no fresh id was minted and MUST be rejected. - **base_crng_gpa** — the direct-map-resolved guest-physical address `base_crng` was read at (the same static kernel symbol in all four captures). MUST be non-zero and MUST place the conservative 56-byte `base_crng` struct footprint inside the image (`base_crng_gpa + 56 <= image_size_bytes`); the proof reads only the key (32 B @0) and generation (8 B @40), both inside it. - **image_size_bytes** — byte size of the compared capture images. MUST be a non-zero multiple of 4096. - **sdk_commit** — 7–40 char lowercase-hex git commit of the producing SDK. ### clone-uniqueness-v1 proof family (substrate per-restore VMGenID uniqueness) Carries the SUBSTRATE half of the severed claim: two ZERO-execution paused restores of the same source snapshot are byte-equal on the complement of the disclosed device-IO exclude regions, while the 16-byte VMGenID region provably DIFFERS between them — the substrate mints fresh clone entropy per restore (the anti-snapshot-clone-entropy-reuse property, Brooker-class, arXiv:2102.12892, on the substrate side). **Uniqueness-only semantics**: this family asserts only that the named mask region differs between the two captures. There is NO complement-equality clause carried in the proof (the proof binds to its substrate via `source_snapshot_merkle_root`; the complement-byte-equal half is enforced by the producer before a proof is assembled, never re-asserted as a signed field) and NO oracle fields anywhere (there is no fold to police and no reference merge to agree with — unlike fold-correctness, this claim is about the substrate minting entropy, not about a differential fold). A verifier MUST NOT infer fold correctness or complement equality from this proof, and MUST NOT infer clone-uniqueness from any other family. ```json { "clone_uniqueness_proof": { "proof_type": "clone-uniqueness-v1", "image_a_merkle_root": "", "image_b_merkle_root": "", "source_snapshot_merkle_root": "", "mask_profile": ["vmgenid_16b"], "exclude_profile": ["virtio_used_ring", "kvmclock_wallclock"], "vmgenid_gpa": "", "image_size_bytes": "", "exclude_coverage": { "virtio_used_ring": {"region_count": "", "excluded_bytes": ""}, "kvmclock_wallclock":{"region_count": 1, "excluded_bytes": 12} }, "exclude_derivation": { "vmstate_sha256": "", "snapshot_metadata_sha256": "" }, "sdk_commit": "" } } ``` Field semantics and verifier gates (the producer validates the identical shape before any bytes hit disk — `pkg/byteident/clonewire.go`): - **proof_type** — MUST be exactly `clone-uniqueness-v1`. The discriminator that severs this family from the fold-correctness and entropy-uniqueness families; a verifier MUST reject any artifact whose proof_type it does not recognize. - **image_a_merkle_root / image_b_merkle_root** — per-page (4 KiB-leaf) SHA-256 Merkle roots of the two zero-execution paused-restore captures, 64 lowercase hex chars each. These are **capture commitments**; the two roots MUST differ: a passing uniqueness verdict requires the VMGenID region to differ, which forces a leaf — hence the root — to differ, so equal roots are internally inconsistent with the claim and MUST be rejected. - **source_snapshot_merkle_root** — the per-page SHA-256 Merkle root of the snapshot both captures were restored from, 64 lowercase hex chars. It binds the clone-uniqueness claim to a specific substrate state, so a verifier can confirm the captures derive from a known snapshot rather than from two unrelated images. There is no equality semantics among the three roots beyond the image_a≠image_b gate above. - **mask_profile** — the closed per-family asserted-unique region vocabulary. For v1 it MUST be exactly `["vmgenid_16b"]`; any other length, entry, or order MUST be rejected. The pvclock page is quiescent across paused restores and so is NOT asserted unique. The closed per-family vocabulary keeps a verifier from ever inferring one family's claim from another's artifact. - **exclude_profile** — the disclosed device-IO regions EXCLUDED from the byte-equality check, drawn from the CLOSED clone-uniqueness exclude vocabulary `{virtio_used_ring, kvmclock_wallclock}`. The profile MAY be empty (a guest with no active virtqueues and no wall-clock record). `virtio_used_ring` covers the used rings the device-emulation thread may advance under a paused vCPU; `kvmclock_wallclock` covers the `struct pvclock_wall_clock`, which KVM re-stamps on every paused restore — this region is legal HERE but NOT in the fold-correctness proof (whose same-instant captures see an identical wall-clock page). Excluded regions carry NO uniqueness assertion. - **vmgenid_gpa** — the guest-physical address of the 16-byte generation-id region (the same architecture constant in both images). MUST be non-zero and MUST place a 16-byte region inside the image (`vmgenid_gpa + 16 <= image_size_bytes`). A verifier additionally asserts the value equals its OWN architecture constant, so the proof demonstrably checked the SAME region the verifier's constant names (closing the cross-language constant-desync hole). - **image_size_bytes** — byte size of the compared capture images. MUST be a non-zero multiple of 4096. - **exclude_coverage** — per-region byte counts (`region_count`, `excluded_bytes`) quantifying the excluded extent from the artifact alone. Its keys MUST be exactly the region types `exclude_profile` names (a missing entry leaves a named exclusion unquantified; an extra entry quantifies an exclusion the profile denies). When present, `virtio_used_ring`'s `excluded_bytes` MUST be page-rounded (used rings are page-rounded by construction), and `kvmclock_wallclock` MUST be exactly one 12-byte span (`region_count` 1, `excluded_bytes` 12 — there is exactly one `struct pvclock_wall_clock` per guest). The coverage total MUST NOT exceed `image_size_bytes`. - **exclude_derivation** — the exclude profile's provenance: `vmstate_sha256` (64 lowercase hex) is ALWAYS present (the virtio used-ring addresses are sourced from the snapshot vmstate). `snapshot_metadata_sha256` (the wall-clock region's provenance) is present **if and only if** the profile excludes `kvmclock_wallclock`: a wall-clock exclusion with no snapshot-metadata digest is unprovenanced, and a snapshot-metadata digest with no wall-clock region claims provenance nothing uses — both MUST be rejected. (This differs from the fold family's `exclude_derivation`, which carries the vmstate digest alone and rejects `snapshot_metadata_sha256` outright.) - **sdk_commit** — 7–40 char lowercase-hex git commit of the producing SDK. ### Cross-host scope (normative caveats) The cross-host T1 claim covers: capture on host A, base+diff memory crossing ONLY via the content-addressed store, fold + this fold-correctness proof emitted on host B (real pinned oracle). SCOPE CAVEATS a verifier MUST respect: (i) guest restore requires a CPU-compatible host pair — a cross-vendor restore is refused at vCPU register restore (the fold and the proof still hold cross-vendor; only restore degrades, T1 fold-proof with T3-unavailable resume); (ii) the witness reference capture and the FC-native diff MUST travel as explicitly-labelled evidence over extent-preserving transport (the diff's sparse-extent map is semantic input to the reference oracle). The dated live-demonstration narrative (hosts, runs, vendor pairs, evidence SHAs) lives in the producer's internal evidence ledger, not in this specification: a verifier binds to the artifact in hand, never to a run narrative it cannot check. ## Verification (third-party, offline) The envelope-level procedure is [signing-envelope.md](signing-envelope.md) §Verification procedure and is not restated here. In summary: confirm `payloadType == application/vnd.in-toto+json`, decode `payload` from base64 and keep the bytes **verbatim** (never re-serialize), resolve each signature's REQUIRED `keyid` against the configured trust anchor, and verify the raw ed25519 signature over `PAE("application/vnd.in-toto+json", )`. The Statement is canonicalized under **RFC 8785 (JSON Canonicalization Scheme)**. Two properties of the trust model are load-bearing here and a verifier MUST enforce both. - **A signing key is authorized per predicate type.** A published verification key carries the set of predicate types it may sign. A verifier MUST reject a statement whose `predicateType` is not in the resolving key's authorized set, so a key issued for trace records cannot mint a substrate attestation even though both verify under ed25519. `https://getprobity.dev/predicate/v1/substrate-integrity` is in the authorized set of the producer's evidence-signing key. - **There is no transparency log, and its absence is deliberate.** Signing does not upload to a public log, so there is no third-party inclusion proof, no independent timestamp, and no public record of the substrate digests an attestation binds. That is the point: the attestation stays exactly as private as the substrate it describes. A verifier MUST NOT treat the absence of a log entry as a defect, and MUST NOT be built to require one. An earlier revision of this document instructed a verifier to "check signature + Rekor inclusion"; no such entry has ever existed for this predicate, and a verifier that required one would reject every conforming artifact. `cosign verify-blob` is NOT the verification path for this predicate. That command checks a detached signature over a blob, which is the [kernel-substrate](kernel-substrate.md) v1 wire form, not a DSSE envelope. A substrate-integrity attestation is verified by any DSSE-aware implementation over the PAE of its payload — the reference verifiers are the `packages/verify` TypeScript library and `website/public/verify/probity-verify.py`. Once the envelope verifies, dispatch on `proof_type` FIRST; each proof_type's field gates in its own predicate section are normative for verification. The path below differs by family, and a verifier MUST NOT carry one family's gates onto another's artifact. - **fold-correctness-v1** — check `proof_type` is exactly `fold-correctness-v1` → apply the "Field semantics" gates above (the closed member set, the closed `exclude_profile` vocabulary, `exclude_coverage` keys matching the profile exactly with a page-rounded used-ring total not exceeding `image_size_bytes`, `exclude_derivation` carrying `vmstate_sha256` and no `snapshot_metadata_sha256`, and the `reference_oracle` shape) → optionally recompute the folded Merkle root against the pinned FROZEN_SUT substrate and compare to `folded_merkle_root`. No live VM, no hypervisor access, and no network access required. A verifier MUST reject any proof whose `used_real_firecracker` is not `true`, and MUST NOT treat a fold-correctness proof as evidence of entropy uniqueness. - **clone-uniqueness-v1** — after the signature check, verify against the "clone-uniqueness-v1 proof family" section's gates: the closed `mask_profile` (`["vmgenid_16b"]`) and `exclude_profile` (⊆ `{virtio_used_ring, kvmclock_wallclock}`) vocabularies, and the VMGenID-differs gate (`image_a_merkle_root ≠ image_b_merkle_root`, `vmgenid_gpa` placing a 16-byte region in the image and equal to the verifier's own architecture constant). No fold/oracle fields are present or expected. - **vmgenid-reseed-v1** — after the signature check, verify against the "vmgenid-reseed-v1 proof family" section's gates: the four-leg `base_crng` gen/key relation (`gen_control == gen0` ∧ `key_control == key0`; `gen_test == gen_test2 == gen0+1`; `key_test`, `key_test2` pairwise-distinct from each other and from `key0`) together with `vmgenid_a_sha256 ≠ vmgenid_b_sha256`. No `mask_profile`/`exclude_profile` and no oracle fields are present or expected. The retired `entropy-uniqueness-v1` getrandom witness is NOT a verification target — it is documented (above) only as an unsound predecessor of `vmgenid-reseed-v1`. A verifier MUST reject an `entropy-uniqueness-v1` artifact rather than verify it. ## What a verifier can and cannot independently check (honesty) - A verifier CAN confirm the statement is authentic under a key it configured and authorized for this predicate type, that it binds exactly the FROZEN_SUT digest it intended, that the proof family is one it recognizes, and that every field gate for that family holds. All of that is offline from the statement and the public key alone. - A verifier CAN recompute a Merkle root and compare it to a carried one **only when it supplies the memory image** — a real check, but relative to an anchor the auditor brings, never derivable from the statement alone. - A verifier CANNOT, from the statement alone, confirm that the captures came from a real Firecracker run rather than being asserted, that the oracle comparison was actually performed, or that the excluded regions were located by the provenance the `exclude_derivation` digest names. `oracle_agreement` and `used_real_firecracker` are producer self-assertions carried under signature; the producer refuses to assemble a proof when either is false, so a conforming artifact never carries a negative — which means their presence records the producer's gate, not an independent check. ## Status and maturity - **Format, producers, and wire validators: implemented.** The Go wire validators (the byte-identity package) and the Python contract models (the verdict-bundle package) enforce the same per-family gates, and the chain harness in `scripts/e2e_step0.py` emits and offline-verifies substrate-integrity and clone-uniqueness bundles in one run. - **Verifier recognition: implemented.** The predicate type is in the recognized set of both the `packages/verify` TypeScript library and `website/public/verify/probity-verify.py`, and in the authorized-predicate set of the published evidence-signing key. - **`entropy-uniqueness-v1`: retired.** It is documented as an unsound predecessor and is a rejection target, not a verification target. - **Standardization status: wave 2.** The standard [README](README.md) lists the uniqueness families as a wave-2 group. This document is normative for the type URI and is published so the URI resolves; wave-2 membership concerns the standardization track, not whether the format is stable. ## Related standards - **[signing-envelope.md](signing-envelope.md)** - the DSSE / in-toto envelope, `keyid` derivation, trust root, and offline-verify procedure this predicate uses without deviation. - **[signed-evidence](signed-evidence.md)** - carries this predicate's `folded_merkle_root` as its `merkle_evidence.guest_memory_merkle_root`, plus the value-binding and fold pins. - **[security-verdict](security-verdict.md)** - Attestation B, which references this attestation by digest. A verdict is only as trustworthy as the substrate attestation behind it. - **[kernel-substrate](kernel-substrate.md)** - a different substrate statement with a different wire form. That one is a detached-signature bundle verified by byte-equality against a reconstructed pre-image; this one is a DSSE / in-toto Statement. Do not carry one's verification path onto the other. - **[in-toto attestation](https://github.com/in-toto/attestation)** - ALIGNED. The payload is an in-toto Statement v1; subject binding and `predicateType` routing are used as in-toto defines them. - **[DSSE](https://github.com/secure-systems-lab/dsse)** - ALIGNED, PAEv1 and ed25519, no deviation. - **[Sigstore / cosign](https://docs.sigstore.dev/)** - NOT USED. There is no Fulcio certificate and no Rekor transparency-log entry; see Verification. - **[SLSA provenance](https://slsa.dev/spec/v1.0/provenance)** - RELATED, not overlapping. Provenance describes how an artifact was built; this predicate proves a property of the substrate it runs on. ## Versioning This is the substrate-integrity v1 predicate. Each proof family's member set is closed at the producer: a producer MUST NOT emit a member not defined in that family's section, and the producer models refuse to sign a body carrying one. Per the standard's [forward-compatibility rule](README.md), a verifier MUST ignore a member it does not recognize rather than rejecting the statement; the vocabulary rule below is the deliberate exception, because an unknown enum VALUE for a known field is one a verifier cannot interpret and so cannot trust. The `exclude_profile` vocabulary is a CLOSED registry: `virtio_used_ring` is the only legal v1 entry, and a verifier MUST reject any entry it does not recognize (fail-closed; no warn-mode verifier exists or is planned). Adding a name to the registry is a spec revision that ships a new published registry list — never a silent additive change a deployed verifier is expected to tolerate. The `mask_profile` vocabularies are likewise CLOSED per family: `clone-uniqueness-v1` admits exactly `vmgenid_16b`, and `entropy-uniqueness-v1` admits exactly the ordered pair `vmgenid_16b`, `csprng_entropy`; a verifier MUST reject any artifact carrying a different profile (same spec-revision rule applies to additions). The `clone-uniqueness-v1` `exclude_profile` is a CLOSED vocabulary `{virtio_used_ring, kvmclock_wallclock}` (possibly empty); a verifier MUST reject any entry it does not recognize. Any change to the Merkle construction or the fold-correctness semantics → v2. Each uniqueness claim arrives as its own proof_type (and predicate section) — `clone-uniqueness-v1`, `entropy-uniqueness-v1`, and `vmgenid-reseed-v1` did — never as a mutation of this one. The `vmgenid-reseed-v1` family carries no `mask_profile`/`exclude_profile` vocabulary; its closed shape is the fixed four-leg base_crng relation (`gen_control == gen0` ∧ `key_control == key0`; `gen_test == gen_test2 == gen0+1`; `key_test`, `key_test2` pairwise-distinct from each other and from `key0`; `vmgenid_a ≠ vmgenid_b`), and any change to that relation or to the base_crng read geometry → a new proof_type, never a silent additive change a deployed verifier is expected to tolerate. ============================================================================== # DOCUMENT: v1/kernel-substrate # CANONICAL: https://getprobity.dev/predicate/v1/kernel-substrate # MARKDOWN: https://getprobity.dev/predicate/v1/kernel-substrate.md ============================================================================== # Probity Kernel-Substrate Attestation, v1 **predicateType:** `https://getprobity.dev/predicate/v1/kernel-substrate` A kernel-substrate attestation is a signed, offline-verifiable statement that a publisher approved **exactly one set of guest-kernel bytes**, identified by the immutable OCI manifest-index digest of the published kernel image, and that the publisher labels those bytes with a named kernel version. Where the verdict predicates in this standard describe what happened to an artifact that was run, this predicate describes the substrate the run happens *on*: the kernel image a runtime is permitted to boot a microVM with. It is the cryptographic anchor the [launch-chain](launch-chain.md) attestation extends. The statement is intentionally the smallest thing that can carry that meaning. Its whole content is "these bytes are approved", the bytes are named by digest, and the version is a label carried under the signature rather than a derived fact. This predicate does **not** ride the DSSE / in-toto envelope specified in [signing-envelope.md](signing-envelope.md). It is signed as a standalone canonical payload under a domain-separated Pre-Authentication Encoding, described in full below. That divergence is a property of the v1 wire form and is stated here rather than left for a reader to discover by failing to parse an in-toto Statement (see Relationship to the v2 wire form). The reference implementation is the engine's kernel-attestation component: the canonical payload builder, the verifier, and the mandatory-enforcement predicate, together with the embedded trust root it verifies against and the release workflow that produces the signed bundle. 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). ## Subject The subject is the **approved guest-kernel image, by OCI manifest-index digest**. In this v1 wire form the subject is not an in-toto `subject` array; it is the `subjectDigest` member of the signed payload itself. The digest is the multi-platform manifest-index digest, in OCI form `sha256:<64 lowercase hex>`, of the kernel image as published to its registry. It is deliberately the index digest and not a mutable registry tag: a force-pushed tag can substitute different bytes, and a digest cannot. ## Signed payload The signed pre-image is a single JSON object with exactly three members: ```json {"predicateType":"https://getprobity.dev/predicate/v1/kernel-substrate","subjectDigest":"sha256:<64-hex>","version":""} ``` The byte layout is normative and is the whole reason a third party can reconstruct it: - The members appear in the order `predicateType`, `subjectDigest`, `version`. This is the field declaration order of the producer's struct, and **declaration order is the format** for this predicate. It is not a sorted-member canonicalization; a producer that sorts members by name happens to agree here only because these three names are already in sorted order, and MUST NOT rely on that coincidence. - There is no insignificant whitespace, no indentation, and no trailing newline. - Every value is a JSON string. There are no maps and no arrays, so no ordering question remains open. A producer MUST emit these bytes and no others. A verifier MUST NOT re-serialize a decoded payload; it compares the bytes it was handed against bytes it reconstructs itself. ## Field semantics - **`predicateType`** (REQUIRED, string) - MUST be exactly `https://getprobity.dev/predicate/v1/kernel-substrate`. It is carried inside the signed bytes, so a byte-equality check against a reconstructed pre-image transitively binds it; a verifier that performs that check need not compare the field separately. - **`subjectDigest`** (REQUIRED, string) - the OCI manifest-index digest of the approved kernel image, in `sha256:<64 lowercase hex>` form. This is the artifact the statement is about. A signature over a payload naming a *different* digest is a downgrade attack, not a partial match; see Verification. - **`version`** (REQUIRED, string, non-empty) - the kernel version the publisher associates with those bytes (for example `6.1.137`). **It is a label under signature, not a derived fact.** Nothing in the attestation, and nothing a verifier can do with it, establishes that the named version is what produced those bytes. It exists so the digest can be read against a human-meaningful release, and so the [launch-chain](launch-chain.md) statement can be tied to the same release. There are no optional members in v1. A producer MUST NOT add one: an additional member changes the signed bytes, and byte-equality verification then fails everywhere rather than degrading gracefully. Additive evolution for this predicate is a new major version, not a new field. ## Signature construction The signature is a raw ed25519 signature over the DSSE **Pre-Authentication Encoding (PAE)** of the payload - never over the bare payload bytes. ``` PAE = "DSSEv1 " || LEN(payloadType) || " " || payloadType || " " || LEN(body) || " " || body ``` where `LEN` is the ASCII decimal byte length, `body` is the canonical payload above, and ``` payloadType = "application/vnd.probity.kernel-attestation.v1+json" (50 bytes) ``` Binding the payload type inside the signed bytes is the domain separation that makes a signature minted for a kernel-substrate attestation unusable as any other Probity payload class under the same key, and vice versa. A verifier MUST reconstruct the PAE itself and MUST NOT verify over the payload alone. Note that the payload type is **not** `application/vnd.in-toto+json` and there is no DSSE envelope object: the PAE construction is borrowed, the envelope is not. The signature is detached. ## Bundle format and distribution The attestation is distributed as a **bundle**: a JSON object pairing the exact signed payload bytes with the detached signature. ```json { "payload": "", "signature": "" } ``` - **`payload`** (REQUIRED) - base64 of the canonical payload. It is base64-encoded specifically so the exact bytes survive a JSON round-trip; the signature commits to the decoded bytes. - **`signature`** (REQUIRED) - base64 of the raw ed25519 signature. This is the value `cosign sign-blob --output-signature` emits. The bundle carries no `keyid`, no certificate, and no transparency-log entry. Key identity is established out of band by the trust root a verifier configures (below). A publisher SHOULD make the bundle resolvable from the pinned kernel digest alone, with no out-of-band tag lookup. The reference implementation publishes it as a one-layer OCI artifact - a tar whose single regular file entry is the bundle JSON - at the deterministic ref ``` /kernel:attestation- ``` where `` is `subjectDigest` with the `sha256:` prefix removed. The bundle is kept in the same registry, under the same authentication, as the kernel it describes, so it is exactly as private as the image. A consumer MUST cap the size of a bundle it reads from a registry or from disk. The reference implementation caps both at 1 MiB and reports an oversized bundle explicitly rather than truncating it, because a silent truncation resurfaces downstream as a misleading parse error. ## Trust root Verification is against a configured ed25519 public key. The reference implementation embeds one key, in PKIX/PEM form, in the verifying binary (`pkg/kernel/keys/probity.pub`), memoized and parsed once; the matching private key is an out-of-band CI secret and is never in a repository. The same key identity verifies the kernel attestation and the verdict bundles the runtime emits. The key is cosign-compatible, so a third party with the public key and a bundle can verify without any Probity code: ``` cosign verify-blob --key probity.pub --signature ``` where `` holds the PAE bytes reconstructed per the construction above and `` holds the bundle's base64 `signature`. The producer signs `` with `cosign sign-blob --yes --key env:// --tlog-upload=false`, so the in-process verifier and the CLI verify over identical bytes. `--tlog-upload=false` is load-bearing: no entry is written to a public transparency log, which keeps the attestation as private as the artifact. ## Verification (third-party, offline) A verifier begins from the digest and version it intends to bind - not from the bundle. 1. Reconstruct the canonical payload for the intended `subjectDigest` and `version`, per Signed payload. 2. Decode the bundle's `payload` from base64 and require it to be **byte-equal** to the reconstruction. A byte-equal payload is strictly stronger than a field-by-field comparison: it binds the digest, the version, and the `predicateType` in one check. A payload that differs MUST be refused as a different statement, never repaired or re-parsed leniently. 3. Reconstruct the PAE over the decoded payload with the payload type above. 4. Decode the bundle's `signature` from base64 and verify it as a raw ed25519 signature over the PAE under the configured public key. The attestation is verified only when steps 2 and 4 both succeed. No network access, no registry, and no live execution environment is required once the bundle and the key are in hand. Order matters. Checking the signature first and the binding second would accept a validly signed statement about a *different* kernel, which is the whole shape of a downgrade attack. ## Enforcement posture This is the fail-closed rail of the pair. - **Enforcement is mandatory by default.** In the reference implementation the predicate that decides this is `mandatoryAttestation()`, defined as the negation of the development escape below, so with no environment configuration at all a missing attestation bundle is a hard failure and the kernel is not used. The justification is a producer-side invariant: a digest is promoted into the runtime's pin manifest only by a CI gate that has already verified a freshly minted bundle against the trust root, so a real bundle exists for every pinned digest and an absent one means something is wrong. - **A present bundle that fails verification is always a hard failure.** A malformed bundle, a signature that does not verify, or a payload that binds a digest other than the one requested is refused unconditionally. No environment variable, including the one below, relaxes this. - **One narrow escape exists, and it covers absence only.** Setting the environment variable `PROBITY_DEV_ALLOW_UNATTESTED_KERNEL` to `1`, `true`, or `yes` (compared case-insensitively after trimming surrounding whitespace) downgrades an **absent** bundle to a logged warning and a not-verified result, so a developer can build against a kernel whose bundle has not been published yet. It is explicitly named, it is the only way to disable the gate, and the fail-closed posture is restored the moment it is unset. It is never appropriate in production, and it does not make a forged bundle acceptable. A consumer implementing this specification independently SHOULD adopt the same shape: default-deny on absence, unconditional-deny on a present-but-invalid bundle, and any relaxation named explicitly rather than folded into a general warn-only mode. Cached copies deserve one note, because the failure they prevent is quiet. A locally cached bundle MUST be keyed by the digest it binds, not by the version alone. A republished kernel keeps its version and mints a new digest, so a version-keyed cache would silently reuse the previous digest's bundle; a digest-keyed cache makes a digest change a cache miss by construction. ## What this predicate does NOT assert (coverage) The coverage statement is part of the contract. A conforming kernel-substrate attestation says nothing about any of the following, and a consumer that reads any of them into it has over-read the statement: - **That the named version produced these bytes.** `version` is an unverified label. - **Anything about the rest of the boot.** The initrd, the kernel command line, and the dm-verity root hashes are outside this statement entirely; they are the [launch-chain](launch-chain.md) predicate's subject. An approved kernel booted with a substituted command line is fully consistent with a valid kernel-substrate attestation. - **Anything about how the kernel was built.** There is no build provenance here: no source revision, no builder identity, no toolchain, no reproducibility claim. This is an approval statement, not a SLSA provenance statement. - **Anything about the kernel's behaviour, configuration, or security properties.** The statement is that the publisher approved these bytes, not that the bytes are good. - **Public discoverability or non-repudiation via a transparency log.** Signing deliberately does not upload to a public log, so there is no third-party inclusion proof and no independent timestamp. - **Revocation.** Nothing in the bundle expires or is revoked. A consumer that needs revocation obtains it out of band. - **That the publisher's key is the right key.** Key identity is a trust-anchor configuration decision, not something the bundle establishes about itself. ## What a verifier can and cannot independently check (honesty) - A verifier CAN confirm the bundle is authentic under a configured key, that it binds exactly the kernel digest and version the verifier intended, and that its payload type is domain-separated from every other Probity signature class. All of that is checkable offline from the bundle and the public key alone. - A verifier CANNOT, from the attestation alone, confirm that the digest names a kernel with any particular property, that the version string is accurate, that the approval decision behind the signature was sound, or that a more recent approval has superseded this one. The guarantee is integrity, provenance under a named key, and exact subject binding - not a judgement about the artifact. ## Status and maturity - **Format, signing, and verification: implemented.** The producer (a CI workflow that builds the canonical payload, builds the PAE, signs it with static-key `cosign sign-blob`, verifies its own output before promoting anything, re-resolves the digest to close the promotion TOCTOU, and pushes the bundle to the deterministic ref) and the in-process verifier both exist and agree byte-for-byte. - **Enforcement: live and fail-closed by default**, per Enforcement posture. - **Trust model: single static key.** A configurable multi-anchor or keyless model is not part of this predicate's v1 wire form. ## Relationship to the v2 wire form A successor exists in the reference implementation at `https://getprobity.dev/predicate/v2/kernel-substrate`. It is a schema change, not only an envelope change: in v2 the approved bytes move out of the predicate body and become the in-toto Statement subject, the predicate carries the version alone, and the statement is canonicalized under RFC 8785 and wrapped in the DSSE envelope this standard specifies elsewhere. Removing a member is breaking by this standard's own versioning rule, which is why it is a new URI rather than a revision of this one. This document remains normative for the v1 URI. Bundles already minted under `.../predicate/v1/kernel-substrate` keep addressing it, and a verifier presented with one MUST apply the v1 rules above rather than in-toto Statement parsing. A consumer dispatches on the full `predicateType` string, so the two never collide. ## Related standards - **[DSSE](https://github.com/secure-systems-lab/dsse)** - PARTIALLY ALIGNED. The PAE construction and its domain-separation property are taken verbatim; the envelope object, its `payloadType` value, and its `keyid` field are not used in this v1 wire form. - **[in-toto attestation](https://github.com/in-toto/attestation)** - NOT USED in v1. The subject lives in the payload as `subjectDigest` rather than in an in-toto `subject` array. The v2 successor above is the in-toto-shaped form. - **[Sigstore / cosign](https://docs.sigstore.dev/)** - PARTIALLY ALIGNED. Static-key `cosign sign-blob` / `verify-blob` over the PAE bytes, deliberately without Fulcio certificates and without Rekor transparency-log upload. - **[SLSA provenance](https://slsa.dev/spec/v1.0/provenance)** - RELATED, not overlapping. Provenance describes how an artifact was built; this predicate records that a publisher approved specific bytes. - **[launch-chain](launch-chain.md)** - the companion predicate that binds the rest of the boot pre-image. This one is its anchor. - **[signing-envelope.md](signing-envelope.md)** - the DSSE / in-toto envelope the rest of this standard uses, and which this v1 predicate deliberately does not. ## Versioning This is the kernel-substrate v1 predicate and its member set is closed. Because verification is byte-equality over a canonical pre-image, there is no additive-optional-field path: adding, renaming, removing, or reordering a member changes the signed bytes and is breaking. Any such change is a new major version URI. The additive-field forward-compatibility rule in the standard's [README](README.md) applies to predicates carried inside the in-toto envelope; it does not apply to this v1 wire form, and stating that plainly is preferable to implying a tolerance no verifier of this predicate can offer. ============================================================================== # DOCUMENT: v2/kernel-substrate # CANONICAL: https://getprobity.dev/predicate/v2/kernel-substrate # MARKDOWN: https://getprobity.dev/predicate/v2/kernel-substrate.md ============================================================================== # Probity Kernel-Substrate Attestation, v2 **predicateType:** `https://getprobity.dev/predicate/v2/kernel-substrate` A kernel-substrate attestation is a signed, offline-verifiable statement that a publisher approved **exactly one set of guest-kernel bytes**, identified by the immutable OCI manifest-index digest of the published kernel image, and that the publisher labels those bytes with a named kernel version. Where the verdict predicates in this standard describe what happened to an artifact that was run, this predicate describes the substrate the run happens *on*: the kernel image a runtime is permitted to boot a microVM with. It is the anchor the [launch-chain](launch-chain.md) attestation extends. **v2 is a different wire form, not a revision of the v1 one.** The [v1 predicate](../v1/kernel-substrate.md) signs a bespoke three-member JSON object whose member declaration order is the format, under a private payload media type, with no `_type`, no in-toto `subject`, and no DSSE envelope; a v1 verifier reconstructs those bytes and compares them whole. A v2 statement is an [in-toto Statement v1](https://github.com/in-toto/attestation/blob/main/spec/v1/statement.md) canonicalized under [RFC 8785](https://www.rfc-editor.org/rfc/rfc8785) and carried in the DSSE envelope specified in [signing-envelope.md](../v1/signing-envelope.md); a v2 verifier checks the signature and then *reads* what it verified. Because the approved bytes move out of the predicate body and become the Statement subject, the predicate loses a member, and removing a member is breaking under this standard's own versioning rule. Hence a new URI rather than a revision. The reference implementation is the engine's attestation package (statement construction, envelope construction, verification and decoding), its producer-side minting sibling, and the `probity-attest` command, which is the only producer of these bytes. 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). ## Subject The subject is the **approved guest-kernel image, by OCI manifest-index digest**, carried in the in-toto `subject` array rather than inside the predicate. ```json "subject": [{"digest": {"sha256": "<64 lowercase hex>"}, "name": "guest-kernel"}] ``` - The digest is the multi-platform manifest-index digest of the kernel image as published to its registry. It is deliberately the index digest and not a mutable registry tag: a force-pushed tag can substitute different bytes, and a digest cannot. - The digest value is **bare lowercase hex with no `sha256:` prefix**. That is the in-toto convention, and it is deliberately not the OCI form the pin manifests are written in. Normalizing between the two spellings is the single most likely implementation error in this format, which is why the reference implementation normalizes in one exported helper (`attest.BareSHA256`) rather than at each call site. Uppercase hex is rejected rather than folded to lowercase: the signed bytes carry one spelling, and quietly accepting a second would make two distinct statements compare equal. - `name` is REQUIRED, non-empty, and conventionally `guest-kernel`. It is inside the signed bytes and so must be deterministic, but **it is never a binding input**: a verifier binds on the digest map and MUST NOT bind on the name. The mutable registry tag is deliberately not used as the name, on the principle that a string a registry operator controls has no business inside a signature. - The array MUST be non-empty. In-toto permits several subjects; a verifier that reports which subject matched MUST report the one that actually carried the digest it bound, not whichever subject happens to be first. ## Statement and canonicalization The signed payload is the RFC 8785 canonical serialization of the Statement: ```json {"_type":"https://in-toto.io/Statement/v1","predicate":{"version":"6.1.137"},"predicateType":"https://getprobity.dev/predicate/v2/kernel-substrate","subject":[{"digest":{"sha256":"04d1...9c8d"},"name":"guest-kernel"}]} ``` - **Member order is RFC 8785 order, which is sorted by member name.** At the Statement level that is `_type`, `predicate`, `predicateType`, `subject`. This is not the field order of any struct and nothing in the format depends on one. It is the observable difference from the v1 wire form, where the producer's declaration order *was* the format and `predicateType` came first; here it comes third because `p` sorts after `_` and before `s`. - There is no insignificant whitespace, no indentation, and no trailing newline. - A producer MUST emit RFC 8785 canonical bytes. A verifier MUST NOT re-canonicalize the payload as part of verification and MUST NOT reconstruct the payload in order to compare it; see Verification. **The `<`, `>` and `&` prohibition of the v1 documents is retired here, and the retirement is a fix rather than a relaxation.** It existed because Go's `encoding/json` rewrites those three characters as the six-character sequences `\u003c`, `\u003e` and `\u0026` while other encoders emit them literally, so two conforming-looking producers disagreed on the signed bytes and a byte-equality verifier failed with no diagnostic pointing at the cause. RFC 8785 fixes the escaping: the transform decodes those escapes and re-emits the raw characters, so both encoders land on the same bytes and the divergence has no place left to occur. The prohibition was a producer rule standing in for a fix; the fix exists, so the rule is removed rather than restated. The reference implementation pins this with `TestCanonicalBytesNeutralizeGoHTMLEscaping` rather than assuming it. ### Parse rules a verifier MUST apply to the verified bytes These are checks on the *already-verified* payload, and every one of them is about two verifiers being unable to disagree about what a signed statement says: - **Duplicate object members are rejected**, anywhere in the document. Go's `encoding/json` silently takes the last occurrence of a repeated member and a strict parser elsewhere takes the first, so a payload carrying `predicateType` twice would mean different things to two conforming readers. Exploiting the ambiguity requires a compromised signer rather than a network attacker, and the fix is cheap. - **Trailing data after the top-level value is rejected**, and input holding no complete top-level value is rejected. - **Nesting is capped at 128 levels.** That is the same bound the producer's canonicalizer applies, and the agreement is the point: a payload a verifier accepts must be one the producer's canonicalizer could have produced. A conforming statement nests three levels. - **Unknown members are ignored, never rejected.** See Versioning; this tolerance is the reason the format moved off byte-equality at all. The reference implementation scans for duplicates with an iterative frame stack rather than a recursive walk, because the input is attacker-influenced and a Go stack overflow is a fatal error no deferred recover catches. ## Predicate ```json {"version": ""} ``` - **`version`** (REQUIRED, string, non-empty after trimming) - the kernel version the publisher associates with the approved bytes, for example `6.1.137`. **It is a label under signature, not a derived fact.** Nothing in the attestation, and nothing a verifier can do with it, establishes that the named version is what produced those bytes. It exists so the digest can be read against a human-meaningful release, and so the [launch-chain](launch-chain.md) statement can be tied to the same release. The predicate carries the version and nothing else, which is the honest shape: the whole content of the statement is "the publisher approved these bytes", and the bytes are the subject. A producer MAY add optional members; a verifier MUST ignore members it does not recognize. The v1 form could not offer that, because any additional member changed the signed bytes and broke byte-equality verification outright. ## Signature construction The signature is a raw ed25519 signature over the DSSE **Pre-Authentication Encoding (PAE)** of the canonical Statement bytes, never over the bare payload. ``` PAE = "DSSEv1 " || LEN(payloadType) || " " || payloadType || " " || LEN(body) || " " || body ``` where `LEN` is the ASCII decimal byte length, `body` is the canonical Statement above, and ``` payloadType = "application/vnd.in-toto+json" (28 bytes) ``` so a conforming pre-image begins `DSSEv1 28 application/vnd.in-toto+json `. **The domain separator moves in v2, and this is a real relocation of a security property rather than a cosmetic change.** The v1 forms used a per-class private media type (`application/vnd.probity.kernel-attestation.v1+json`, 50 bytes, for this predicate; `application/vnd.probity.launch-chain.v1+json`, 44 bytes, for its companion), so separation between the two substrate classes lived in the PAE. Both v2 statements are signed under the single in-toto payload type, and separation between them now lives in `predicateType`, which a v2 verifier MUST check as a field. Under byte-equality it never had to be examined, because the type string sat inside bytes that were compared whole; here it is an explicit, mandatory check and a verifier that skips it accepts a launch-chain statement where it demanded a kernel approval. Separation from every *other* Probity artifact class is unaffected and still lives in the payload type: a catch record is signed under its own payload type, a Statement under this one, and PAE's length prefixes make the pair injective, so neither signature verifies as the other. A verifier MUST reconstruct the PAE with the payload-type **constant above**, never with the value read off the wire, even after confirming the two agree. A verifier that fed the wire value into its own reconstruction would have handed the attacker the choice of domain-separation label. ## Envelope format and distribution The statement is carried in the DSSE envelope specified in [signing-envelope.md](../v1/signing-envelope.md): ```json { "payload": "", "payloadType": "application/vnd.in-toto+json", "signatures": [{"keyid": "<64 lowercase hex>", "sig": ""}] } ``` - **`payloadType`** (REQUIRED) - MUST equal the constant above. A verifier MUST reject any other value **before doing any signature work**. This is the cheapest place to catch a cross-class replay: a genuine Probity-key envelope of another class, served at a substrate statement's address, carries a signature that is perfectly valid for its own (type, payload) pair. It would be caught later by the `_type` and `predicateType` checks, but there is no reason to let it get that far. This condition was **unrepresentable** in the v1 bundle, which carried no on-wire payload type to disagree with. - **`signatures`** (REQUIRED) - a non-empty array. Each entry MUST carry a `keyid`; an entry without one makes the envelope non-conforming. - **`keyid`** (REQUIRED) - the lowercase-hex SHA-256 of the RFC 7638 JWK thumbprint input for the ed25519 public key, per [signing-envelope.md](../v1/signing-envelope.md). It is excluded from the PAE and therefore unauthenticated, so it is a **selector and nothing more**: a verifier resolves it against configured anchors, independently re-derives it from the resolved key, and rejects when it does not resolve. It never fetches a key, never falls back to trial verification on an unresolvable id, and never treats a matching keyid as evidence of anything. The v1 bundle named no key at all, which is why "I do not trust this signer" and "this signature is forged" were indistinguishable there and are distinct findings here. - A producer MUST derive the keyid from the public key rather than accepting one from a caller. A hand-passed keyid can drift from the key a verifier actually holds, and deriving it means a CI step can assemble the envelope from the committed public half while the private half never reaches that code. A consumer MUST cap the envelope bytes it will consider. The reference implementation caps at **1 MiB**, matching the cap the v1 read paths already applied, so a file one rail will assemble cannot be one the other refuses for size alone. A conforming statement for this predicate is a few hundred bytes. **Distribution for the v2 form is not yet fixed by the reference implementation.** The v1 bundles are published as one-layer OCI artifacts at a deterministic `attestation-` tag in the same private registry as the kernel, under the same authentication, so the attestation is exactly as private as the image. No publishing path for a v2 envelope exists in the reference implementation today, so this document specifies the artifact and not its address. A publisher SHOULD make the envelope resolvable from the pinned kernel digest alone, with no out-of-band tag lookup, and SHOULD keep it under the same authentication as the image it describes. ## Trust root Verification is against a **configured set** of ed25519 public keys, indexed by the keyid derived from each key. Indexing by the derived value is what makes "resolve the keyid, then independently re-derive it from the resolved key and confirm it matches" hold by construction: there is no path that resolves a key under an id the key does not hash to. The set is supplied as configuration. An implementation MAY ship one key as a convenient default, and the reference implementation embeds one in PKIX/PEM form in the verifying binary (`pkg/kernel/keys/probity.pub`), but nothing in the format names or assumes a particular key. An empty anchor set is a configuration mistake and MUST be refused at construction rather than silently rejecting every envelope for a reason unrelated to the envelope. Multiple signature entries are permitted and are the rotation path. An entry whose keyid does not resolve to a configured anchor is **skipped rather than fatal**, so an envelope carrying an old and a new signature does not brick a verifier configured with only one of them; an entry whose `sig` is not decodable base64 is likewise skipped. If nothing resolved, the failure is "untrusted signer"; if something resolved but nothing verified, the failure is a tamper signal. These MUST be distinct outcomes. The key is cosign-compatible, so a third party with the public key can verify with no Probity code: ``` cosign verify-blob --key probity.pub --signature ``` where `` holds the PAE bytes reconstructed per the construction above and `` holds the base64 `sig` from the envelope. The producer signs the same `` with `cosign sign-blob --yes --key env:// --tlog-upload=false`, so the in-process verifier and the CLI verify over identical bytes. `--tlog-upload=false` is load-bearing: no entry is written to a public transparency log, which keeps the attestation as private as the artifact and means there is no third-party inclusion proof to appeal to. ### Anchor lifecycle (reference implementation) The reference implementation's "configured set" above is not a bare list of keys: each embedded key carries a declared `role`, `status` (`active` / `retired` / `revoked`), a `not_before` instant, an optional `not_after` instant, and the list of predicate types it is authorized to act for (`pkg/kernel/keys/anchors.json`, loaded and validated by `pkg/kernel/keys`). This generalizes the bare keyid-indexed anchor set above with a lifecycle a deployment can reason about, and is required because one key in the reference deployment signs more than one predicate class (both versions of both `kernel-substrate` and `launch-chain`) — `authorized_predicates` is the field that keeps that scoped rather than implicit. The manifest never gets to ASSERT a key's identity. Each entry names a key **file**; the loader independently re-derives the keyid from the referenced key's own bytes and refuses to start if the declared `key_id` does not match what the key actually hashes to (`ErrAnchorKeyIDMismatch`). There is no code path on which a hand-edited or mismatched `key_id` is trusted. The status/window policy a verifier applies is deliberately asymmetric, and the asymmetry is the point, not an oversight: - `status: revoked` is excluded **unconditionally, including at boot**, with no carve-out. A revocation only takes effect once a verifier's own configured anchor set is rebuilt (for the reference deployment, when a new binary ships) — it cannot reach a verifier that never re-reads the manifest, which is the same "revocation is a distribution problem, not a decree" property every other section of this spec that touches revocation already states. - `not_before` is boot-enforced: an anchor is not valid before its declared start. - `not_after` is **deliberately NOT boot-enforced**. A signature minted while a key was valid remains a valid historical approval after that key's window closes; hard-failing it at boot would convert an ordinary key-lifecycle event (a window elapsing on a calendar date) into a fleet-wide boot outage on that date. `not_after` is enforced at the CI signing gate (a producer MUST NOT mint a new statement under an anchor whose window has closed) and MAY be enforced by a non-boot verifier that chooses to check it. A validity window is evaluated against a trusted timestamp when one is present on the envelope (see the timestamping extension this predicate's Bundle carrier supports) and against the verifier's own clock otherwise; a verifier MUST report which of the two it used, since a window judged against an untrusted local clock is a materially weaker claim than one judged against a trusted time source. ## Verification (third-party, offline) The v2 procedure is **verify-then-read**, and the ordering is the substantive change from v1. A v1 verifier reconstructs the signed bytes from values it already holds and compares them whole, so binding happens before the signature check. A v2 verifier never reconstructs anything; it checks the signature and then reads the bytes that verified, and binding is a comparison *after* the signature check. Both orderings have exactly one chokepoint and differ only in which side of the signature check it sits on. What must never happen, under either, is a path that returns "verified" while the attested value differs from the value the verifier pinned. 1. **Cap and parse the envelope.** Reject anything over the byte cap and anything that is not a DSSE envelope. 2. **Assert `payloadType`** equals `application/vnd.in-toto+json`. Reject before any signature work. 3. **Assert `signatures` is non-empty and every entry carries a `keyid`.** 4. **Base64-decode `payload`.** 5. **Resolve each entry's `keyid` against the configured anchors** and verify the entry's signature as raw ed25519 over `PAE("application/vnd.in-toto+json", payload)`, reconstructed from the constant. Skip entries that do not resolve or whose signature is not decodable base64. Report "no configured anchor was named" and "an anchor was named but nothing verified" as different failures. 6. **Parse the decoded bytes**, applying the parse rules above (duplicate members, trailing data, depth bound), and assert `_type` is `https://in-toto.io/Statement/v1`, `predicateType` is exactly `https://getprobity.dev/predicate/v2/kernel-substrate`, `subject` is non-empty, and `predicate` is present. A verifier MUST demand a predicate type explicitly; accepting whatever class it is handed is a caller defect that is silent at runtime. 7. **Bind, and this step is MANDATORY.** Compare the subject digest against the kernel digest the verifier independently intends to trust, and the predicate `version` against the version it intends to trust. Steps 1 to 6 establish that the bytes are authentic, that they are an in-toto Statement of the demanded class, and nothing whatsoever about *which artifact* the statement is about. Skipping the comparison leaves a verifier holding a good signature over something else, which is the whole shape of a downgrade attack. Report a binding mismatch with a distinct sentinel from a signature failure: "signed by someone I trust, about something else" and "forged" are different facts. No network access, no registry, and no live execution environment is required once the envelope and the key are in hand. ### CANON-EQ, and why the boot verifier omits it A payload can be signed and yet not be RFC 8785 canonical, if a producer is defective. Asserting that the verified payload is already canonical (re-canonicalizing it and requiring equality) is called CANON-EQ here, and where it runs is a deliberate asymmetry: - A **boot-critical verifier deliberately does NOT run it.** Enforcing it there would import a JSON canonicalizer, and its unbounded recursive-descent parser, into a boot path in order to defend against a defect that originates in the producer. The reference implementation keeps the canonicalizer out of the verifier's import graph entirely by splitting the producer into a separate package, and pins the split with a test, because a Go import is resolved per package and a single shared package would silently undo it. - A **producer, a CI promotion gate, an auditor, and any third-party verifier that is not boot-critical SHOULD run it.** The reference `probity-attest verify` runs it and refuses a payload that fails, and the producer refuses to sign non-canonical bytes in the first place. The honest cost is that a producer bug emitting non-canonical-but-signed bytes would reach a boot path; the mitigation is that the promotion gate will not publish it, and that the mandatory subject binding contains what it could do if it did. ## Enforcement posture **The v2 form is not on any enforcement path in the reference implementation.** Nothing in the boot path calls the v2 verifier; the v1 bespoke bundle path is untouched and is still the only thing that runs there. Consequently: - **No environment variable governs the v2 form.** The two named in the v1 documents, `PROBITY_DEV_ALLOW_UNATTESTED_KERNEL` and `PROBITY_REQUIRE_LAUNCH_CHAIN`, govern the v1 kernel package alone and have no effect on a v2 statement. This document names no others, because none exist in current source. - **The v1 enforcement posture is unchanged and remains live.** Kernel-substrate enforcement is mandatory by default there, a present-but-invalid bundle is an unconditional hard failure, and the single narrow escape covers an absent bundle only. See [the v1 document](../v1/kernel-substrate.md). A consumer implementing this specification independently SHOULD adopt the same shape when it does enforce: default-deny on absence, unconditional-deny on a present-but-invalid envelope, and any relaxation named explicitly rather than folded into a general warn-only mode. Cached copies deserve one note, because the failure they prevent is quiet. A locally cached envelope MUST be keyed by the kernel digest it binds, not by the version. A republished kernel keeps its version and mints a new digest, so a version-keyed cache would silently reuse the previous digest's statement; a digest-keyed cache makes a digest change a cache miss by construction. ## What this predicate does NOT assert (coverage) The coverage statement is part of the contract. A conforming kernel-substrate attestation says nothing about any of the following, and a consumer that reads any of them into it has over-read the statement: - **That the named version produced these bytes.** `version` is an unverified label. - **Anything about the rest of the boot.** The initrd, the kernel command line, and the dm-verity root hashes are outside this statement entirely; they are the [launch-chain](launch-chain.md) predicate's subject. An approved kernel booted with a substituted command line is fully consistent with a valid kernel-substrate attestation. - **Anything about how the kernel was built.** There is no build provenance here: no source revision, no builder identity, no toolchain, no reproducibility claim. This is an approval statement, not a SLSA provenance statement. - **Anything about the kernel's behaviour, configuration, or security properties.** The statement is that the publisher approved these bytes, not that the bytes are good. - **That the subject `name` identifies anything.** It is inside the signature and it is not a binding input; a verifier that dispatches on it has bound on a string instead of on a digest. - **Public discoverability or non-repudiation via a transparency log.** Signing deliberately does not upload to a public log, so there is no third-party inclusion proof and no independent timestamp. - **Revocation of a key.** Revocation takes effect only when a verifier's configured anchor set is updated — for the reference deployment, when a new binary ships. A revoked key does not stop verifying at deployed verifiers that have not been updated; a consumer that needs faster revocation obtains it out of band. - **Expiry of the statement.** The statement itself never expires. An anchor's `not_after` (see Trust root, above) bounds when that key may SIGN; it does not bound how long a statement it already signed remains readable or how long its signature remains valid to check. - **That the publisher's key is the right key.** Key identity is a trust-anchor configuration decision. The `keyid` selects among keys already trusted; it establishes nothing on its own. - **That the payload was canonical**, unless the verifier ran CANON-EQ. A boot-critical verifier deliberately does not. ## What a verifier can and cannot independently check (honesty) - A verifier CAN confirm the envelope is authentic under a configured anchor, which anchor admitted it, that the payload is an in-toto Statement v1 of exactly this predicate type, and that it binds exactly the kernel digest and version the verifier intended. All of that is checkable offline from the envelope and the public key alone. - A verifier CANNOT, from the statement alone, confirm that the digest names a kernel with any particular property, that the version string is accurate, that the approval decision behind the signature was sound, that a more recent approval has superseded this one, or that the signing key is the one the publisher intends to be trusted today. The guarantee is integrity, provenance under a named key, and exact subject binding - not a judgement about the artifact. ## Status and maturity - **Format, canonicalization, signing construction, and verification: implemented.** One implementation produces the canonical bytes and one verifies them, and they are the same packages, so a producer and a consumer cannot diverge by construction. Committed cross-language conformance vectors carry the public key, the payload type, the canonical bytes, the PAE pre-image, the signature and the assembled envelope for each case, so a verifier in another language needs that file and nothing else. - **Producer: a command exists; no CI step runs it.** `probity-attest` builds the statement, emits the PAE for `cosign sign-blob`, assembles the envelope from the committed public key, and verifies totally. No workflow in the reference implementation mints or publishes a v2 envelope yet, and no distribution address is defined. This is stated plainly rather than left to be inferred from an empty registry tag. - **Consumer: not wired.** No boot path calls the v2 verifier; the v1 path is still the enforcing one. - **Trust model: configured anchor set, single static key in the reference deployment.** Multi-anchor rotation is supported by the format and by the verifier's skip-unresolved behaviour; a keyless or certificate-based model is not part of this predicate. ## Relationship to the v1 wire form [The v1 predicate](../v1/kernel-substrate.md) remains normative for the v1 URI, and bundles already minted under `.../predicate/v1/kernel-substrate` keep addressing it. A verifier presented with a v1 bundle MUST apply the v1 rules - reconstruct the bespoke payload and compare bytes - rather than in-toto Statement parsing. A consumer dispatches on the full `predicateType` string, so the two never collide, and a v1 bundle carries no `payloadType` member for a v2 verifier to accept in the first place. Every difference, in one place: | | v1 | v2 | |---|---|---| | Container | bespoke `{payload, signature}` JSON object | DSSE envelope `{payload, payloadType, signatures[]}` | | On-wire payload type | absent | `application/vnd.in-toto+json`, checked before any signature work | | Signed payload | bespoke 3-member object, no `_type` | in-toto Statement v1 | | Approved digest | `subjectDigest` member inside the payload, OCI `sha256:` form | in-toto `subject[].digest.sha256`, bare hex | | Predicate body | (none; the payload is the predicate) | `{"version": ...}` | | Canonicalization | producer's field declaration order | RFC 8785, sorted member names | | `predicateType` position | first | third (`_type`, `predicate`, `predicateType`, `subject`) | | Key identity on the wire | none | mandatory `keyid`, RFC 7638 thumbprint, selector only | | Domain separation, class to class | private per-class payload type | `predicateType`, checked as a field | | Verification order | reconstruct, compare bytes, then check signature | check signature, then read, then bind | | Unknown members | forbidden; any extra member breaks verification | MUST be ignored | | Duplicate members | unreachable (bytes compared whole) | explicitly rejected | | `<`, `>`, `&` in string values | prohibited by producer rule | permitted; RFC 8785 fixes the escaping | | Multiple signers | not representable | permitted; unresolved keyids skipped | | Untrusted-signer vs forged | indistinguishable | distinct failures | | Enforcement | live, fail-closed by default | not wired to any enforcement path | ## Related standards - **[in-toto attestation](https://github.com/in-toto/attestation)** - USED. The payload is an in-toto Statement v1 and the subject convention (bare-hex digest map, name never a binding input) is in-toto's. - **[DSSE](https://github.com/secure-systems-lab/dsse)** - USED, envelope and PAE both, unlike the v1 form which borrowed the PAE alone. - **[RFC 8785 (JCS)](https://www.rfc-editor.org/rfc/rfc8785)** - USED as the canonicalization, which is what retires the v1 escaping prohibition. - **[RFC 7638 (JWK thumbprint)](https://www.rfc-editor.org/rfc/rfc7638)** - USED as the keyid derivation; see [signing-envelope.md](../v1/signing-envelope.md). - **[Sigstore / cosign](https://docs.sigstore.dev/)** - PARTIALLY ALIGNED. Static-key `cosign sign-blob` / `verify-blob` over the PAE bytes, deliberately without Fulcio certificates and without transparency-log upload. - **[SLSA provenance](https://slsa.dev/spec/v1.0/provenance)** - RELATED, not overlapping. Provenance describes how an artifact was built; this predicate records that a publisher approved specific bytes. - **[launch-chain](launch-chain.md)** - the companion predicate that binds the rest of the boot pre-image. This one is its anchor, and in v2 that relationship is cryptographic: the launch-chain statement's subject is this statement's subject. - **[signing-envelope.md](../v1/signing-envelope.md)** - the envelope this predicate rides, and which its v1 form deliberately did not. ## Versioning The versioning rules in the standard's [README](../v1/README.md) apply to this predicate in full, and they could not apply to its v1 form: - **Major version in the URI.** A consumer dispatches on the full `predicateType` string and MUST reject a type it does not recognize. - **Additive-optional members are a minor change and stay within the major version.** A producer MAY add optional members to the predicate or to the Statement, and a verifier MUST ignore what it does not recognize. This works here precisely because verification never rebuilds the bytes: a member the verifier does not know about is a member it does not read. The conformance vectors carry a case with an unknown predicate member and an unknown statement member for exactly this reason. - **Rename, removal, or any semantic change of an existing member is a new major version URI**, never a silent in-place change a deployed verifier is expected to tolerate. ============================================================================== # DOCUMENT: v1/launch-chain # CANONICAL: https://getprobity.dev/predicate/v1/launch-chain # MARKDOWN: https://getprobity.dev/predicate/v1/launch-chain.md ============================================================================== # Probity Launch-Chain Attestation, v1 **predicateType:** `https://getprobity.dev/predicate/v1/launch-chain` A launch-chain attestation is a signed, offline-verifiable statement about the **rest of the boot pre-image**: everything a microVM is launched with beyond the kernel image itself. It binds the initrd digest, the exact kernel command line, and the ordered set of dm-verity root hashes the rootfs is mounted under. The [kernel-substrate](kernel-substrate.md) attestation says which kernel bytes are approved; this one says what the kernel is approved to be *started with*, which is where a substituted command line or a swapped rootfs image would otherwise pass unnoticed. The two are deliberately separate statements under separate predicate types and separate signature domains, so a signature minted for one can never be replayed as the other under the same key. They are also deliberately at different enforcement postures: the kernel-substrate gate is fail-closed by default, and this one is an explicit, named opt-in. The reasons are recorded under Enforcement posture and are properties of the current deployment, not of the format. This predicate does **not** ride the DSSE / in-toto envelope specified in [signing-envelope.md](signing-envelope.md). Like its companion it is signed as a standalone canonical payload under a domain-separated Pre-Authentication Encoding, described in full below. The reference implementation is the engine's kernel-attestation component: the canonical launch-chain payload builder, its verifier, the enforcement entry point and the mandatory-enforcement predicate, together with the frozen-SUT pin manifest and its loader. 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). ## Subject **The v1 wire form carries no subject member, and this is the predicate's principal known weakness.** The measurements in the payload are the launch chain; the kernel they belong to is not named anywhere inside the signed bytes. The join to a kernel is made at the distribution layer instead: the bundle is published to, and read from, a location derived from the kernel's manifest-index digest (below). That means the binding between a launch chain and its kernel rests on a naming convention a registry operator controls, rather than on a cryptographic fact. A `version` string is carried in the payload and ties the statement to a release by label, but a label is not a binding. A consumer MUST NOT treat a verified launch-chain attestation as evidence that these measurements belong to any particular kernel image. It is evidence that the publisher signed these measurements. The v2 successor closes this by making the kernel index digest the statement subject; see Relationship to the v2 wire form. ## Signed payload The signed pre-image is a single JSON object with exactly five members: ```json {"predicateType":"https://getprobity.dev/predicate/v1/launch-chain","initrdDigest":"sha256:<64-hex>","kernelCmdline":"","rootfsVerityRootHashes":["<64-hex>"],"version":""} ``` The byte layout is normative: - The members appear in the order `predicateType`, `initrdDigest`, `kernelCmdline`, `rootfsVerityRootHashes`, `version`. This is the field declaration order of the producer's struct, and **declaration order is the format** for this predicate. It is emphatically *not* sorted-member order: sorting these five names would put `predicateType` third. A producer that canonicalizes by sorting member names emits different bytes and every verification fails. - There is no insignificant whitespace, no indentation, and no trailing newline. - `rootfsVerityRootHashes` is a JSON array of strings and its element order is significant (see below). There are no JSON objects nested inside the payload and no maps, so no other ordering question remains open. **String escaping.** `kernelCmdline` MUST NOT contain the characters `<`, `>`, or `&`. This is not an aesthetic restriction: some JSON encoders escape those three characters to the six-character sequences `\u003c`, `\u003e`, and `\u0026` while others emit them literally, so two conforming-looking producers would disagree on the signed bytes for the same command line, and byte-equality verification would fail with no diagnostic pointing at the cause. Excluding them from the input removes the divergence. All other characters are escaped identically by any correct JSON encoder. ## Field semantics - **`predicateType`** (REQUIRED, string) - MUST be exactly `https://getprobity.dev/predicate/v1/launch-chain`. It is inside the signed bytes, so a byte-equality check against a reconstructed pre-image transitively binds it. - **`initrdDigest`** (REQUIRED, string) - the OCI digest of the initrd, in `sha256:<64 lowercase hex>` form. **This member has three distinguished values and conflating any two of them is the single most likely implementation error in this predicate:** - a real digest - the boot loads that initrd; - the **empty-input digest** `sha256:e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855`, which is SHA-256 over zero bytes and is the canonical way to say **the boot loads no initrd at all**. A Firecracker direct kernel boot has no initramfs, so there is no file to hash; this value is a real, promoted measurement, not an absence marker; - the **all-zero placeholder** `sha256:0000...0000` (64 zeros), which means **not yet promoted** - the publisher has not committed a real measurement. This is the opposite of the case above: one is a fact, the other is the absence of one. The empty string is not a legal value; it does not satisfy the digest form and cannot be pinned. - **`kernelCmdline`** (REQUIRED, string, non-empty) - the exact kernel command line the boot uses, verbatim and complete. Comparison against it is an **exact string match**, never a prefix, substring, or containment check - the point of binding it is that an added or altered parameter (one that disables a guard, for instance) must not pass. In the reference deployment the per-sandbox boot data deliberately does not travel on the command line, which is what makes an exact match a workable requirement rather than a permanently failing one. - **`rootfsVerityRootHashes`** (REQUIRED, array of strings, non-empty) - the dm-verity root hashes of the rootfs, one per mounted verity target, each as **bare 64 lowercase hex with no `sha256:` prefix** (the `veritysetup` root-hash form, deliberately not the OCI form the initrd digest uses). - **Order is significant and is compared as an ordered sequence, never as set membership.** The position of a hash is which target it is, so two statements listing the same hashes in different orders bind different things and MUST NOT compare equal. - An element MAY be the all-zero root hash `0000000000000000000000000000000000000000000000000000000000000000`, meaning that measurement is not yet promoted. - An empty array is not conforming: it would silently bind no rootfs integrity at all. - A hash pinned here MUST be produced with a **fixed salt** (`veritysetup format --salt=`). `veritysetup` randomizes the salt per invocation by default, and a random salt makes the root hash non-reproducible, so a defaulted salt yields a value nothing can ever re-derive. - **Which targets appear here is a deployment decision, not a rule of this format.** In the reference deployment the array carries the fixed substrate root only - the element-0 init-root, identical for every sandbox - while per-sandbox workload overlay-lower hashes are carried on a separate signed channel and verified inside the guest. The array shape is retained for a future multi-target substrate. A consumer therefore MUST NOT read this array as a complete inventory of every verity target a running guest mounts; it is the set the publisher pinned. - **`version`** (REQUIRED, string, non-empty) - the kernel version the launch chain corresponds to, tying this statement to the same release as the [kernel-substrate](kernel-substrate.md) attestation. As there, **it is a label under signature, not a derived fact**; nothing verifies that the named version is the kernel these measurements were gathered against. There are no optional members in v1, and a producer MUST NOT add one: any additional member changes the signed bytes and byte-equality verification fails outright rather than degrading. ## Signature construction The signature is a raw ed25519 signature over the DSSE **Pre-Authentication Encoding (PAE)** of the payload, never over the bare payload bytes. ``` PAE = "DSSEv1 " || LEN(payloadType) || " " || payloadType || " " || LEN(body) || " " || body ``` where `LEN` is the ASCII decimal byte length, `body` is the canonical payload above, and ``` payloadType = "application/vnd.probity.launch-chain.v1+json" (44 bytes) ``` This type is distinct from - and is not a prefix of - the kernel-substrate payload type, so a signature minted for one statement class can never be replayed as the other under the same key. A verifier MUST reconstruct the PAE itself and MUST NOT verify over the payload alone. As with the companion predicate, the payload type is not `application/vnd.in-toto+json`, there is no DSSE envelope object, and the signature is detached. ## Bundle format and distribution The bundle format is identical to the [kernel-substrate](kernel-substrate.md) one - the same two base64 members, verified by the same recipe - so an implementation shares one code path and differs only in the canonical payload, the payload type, and the enforcement disposition. ```json { "payload": "", "signature": "" } ``` The bundle carries no `keyid`, no certificate, and no transparency-log entry. A publisher SHOULD make the bundle resolvable from the pinned kernel digest alone. The reference implementation publishes it as a one-layer OCI artifact at the deterministic ref ``` /kernel:launch-chain- ``` using a tag prefix distinct from the kernel-substrate bundle's so the two classes never collide, in the same registry and under the same authentication as the kernel. This ref is the *only* thing connecting the launch chain to its kernel; see Subject. A consumer MUST cap the size of a bundle it reads. The reference implementation caps registry and on-disk reads at 1 MiB. A locally cached bundle MUST be keyed by the pinned kernel digest, so that a republished kernel is a cache miss by construction rather than a silent reuse of the previous measurements. ## Trust root Identical to [kernel-substrate](kernel-substrate.md): a configured ed25519 public key, embedded in PKIX/PEM form in the verifying binary in the reference implementation, cosign-compatible, with the private half held out of band. A third party verifies with ``` cosign verify-blob --key probity.pub --signature ``` over the PAE bytes reconstructed per the construction above. ## Verification (third-party, offline) A verifier begins from the measurements it intends to bind - not from the bundle. 1. Reconstruct the canonical payload for the intended `initrdDigest`, `kernelCmdline`, `rootfsVerityRootHashes` (in order), and `version`, per Signed payload. 2. Decode the bundle's `payload` from base64 and require it to be **byte-equal** to the reconstruction. This one check binds all four measurements and the `predicateType` together. 3. Reconstruct the PAE over the decoded payload with the payload type above. 4. Decode the bundle's `signature` from base64 and verify it as a raw ed25519 signature over the PAE under the configured public key. The attestation is verified only when steps 2 and 4 both succeed. No network access and no live execution environment is required once the bundle and the key are in hand. ### Verifying against a live boot An enforcing runtime performs the same procedure with one addition that carries most of the value: **it reconstructs the canonical payload from the measurements the boot is about to actually use, not from the values it pinned.** The bundle binds the pin, so a byte-equality against the live reconstruction succeeds only when live equals pin equals attested. A caller that reconstructs from its own pin has proved only that it agrees with itself. An implementation SHOULD also make the not-verified state unforgeable by construction. The reference implementation returns an enumerated outcome whose zero value is a non-verified state, so a dropped error or an unassigned result can never read as verified; only a present bundle that verified over the live measurements yields the verified value. ## Enforcement posture **This gate is off by default, and that is a deliberate posture difference from the fail-closed [kernel-substrate](kernel-substrate.md) gate.** It is an explicit named opt-in: setting the environment variable `PROBITY_REQUIRE_LAUNCH_CHAIN` to `1`, `true`, or `yes` (compared case-insensitively after trimming surrounding whitespace) turns it on. There is no silent warn-only mode and no third setting. Two preconditions are unmet in the reference deployment, and defaulting the gate on would fail every legitimate boot rather than catch an illegitimate one: 1. **No launch-chain bundle is published.** The CI workflow signs the kernel-substrate attestation only; no step mints or pushes a launch-chain bundle. With the gate on, every boot would fail closed on an absent bundle. 2. **Verity boot is opt-in.** A default, non-verity boot gathers a different command line and an empty verity set, which a field check against the promoted pin would correctly reject - bricking every non-verity sandbox. Making it default-on requires, in order: a producer that signs and publishes the bundle; verity boot as the default so the live boot matches the promoted pin; and only then the flip, with a narrow named development escape of the kind the kernel-substrate gate has. A third condition was met more recently than the first two and is worth recording because its absence was invisible: a staging step that actually fetches the published bundle to the location the verifier reads. Without it, an operator who published a bundle and set the variable would have received a hard absent-bundle failure on every boot, with nothing in the outcome to distinguish "never published" from "never retrieved". Disposition, by state: | State | Gate off (default) | Gate on | |---|---|---| | Pinned measurements still the all-zero placeholder | not verified, proceed | refuse: enforcing against an unpromoted pin would bind zero integrity | | Live command line, verity set, or initrd digest differs from the pin | logged warning, not verified, proceed | hard failure, per-field | | Bundle absent | not verified, proceed | hard failure | | Bundle present, binds different measurements | logged warning, not verified, proceed | hard failure | | Bundle present, malformed or signature invalid | **hard failure** | hard failure | | Bundle present, verifies over the live measurements | verified | verified | The last two rows are the load-bearing ones. A forged or malformed bundle is a hard failure **whether or not the gate is on** - permissiveness extends to absence and to a not-yet-byte-stable boot, never to a bad signature. And the off-the-gate rows are all non-verified: no configuration produces a verified result without a present bundle that checked out over the live boot. Refusing to enforce against an all-zero placeholder pin deserves its own note, since refusing looks like the weaker choice. It is not. Enforcing against an unpromoted pin either bricks a real boot or "passes" a gate that binds nothing, and a gate that passes while binding nothing is worse than an absent gate because it reads as evidence. ## What this predicate does NOT assert (coverage) - **That these measurements belong to any particular kernel.** No kernel digest is in the signed bytes; see Subject. This is the largest gap in the v1 form. - **That the named version is accurate.** `version` is an unverified label. - **Every verity target a guest mounts.** The array is what the publisher pinned - in the reference deployment the fixed substrate root only. Per-sandbox workload measurements travel elsewhere. - **That dm-verity was actually enabled for a given boot.** The statement binds root hashes; whether a particular launch used them is the enforcing runtime's live comparison, not a property of the bundle. - **Anything about the kernel image itself.** That is [kernel-substrate](kernel-substrate.md). - **Any host-side confinement.** A host seccomp filter is explicitly not bound by this v1 payload. It is a candidate for a later revision once the confinement is byte-stable, and until then a consumer MUST NOT infer it. - **Build provenance, transparency-log inclusion, revocation, or key legitimacy** - as for [kernel-substrate](kernel-substrate.md), and for the same reasons. ## What a verifier can and cannot independently check (honesty) - A verifier CAN confirm the bundle is authentic under a configured key and that it binds exactly the initrd digest, command line, ordered verity root set, and version the verifier intended - all offline, from the bundle and the public key alone. An enforcing runtime CAN additionally confirm that the boot it is about to perform uses those same measurements. - A verifier CANNOT confirm, from the attestation alone, which kernel these measurements belong to, that the command line is safe, that the verity root hashes correspond to any particular filesystem image, or that the publisher's promotion decision was sound. The guarantee is integrity, provenance under a named key, and - for an enforcing runtime - equality with the live boot. ## Status and maturity - **Format, signing construction, and verification: implemented.** The canonical payload builder, the PAE domain separation, the byte-equality binding check, the live-measurement enforcement entry point, and the bundle staging path all exist and are exercised. - **Producer: does not exist yet.** No CI step mints or publishes a launch-chain bundle. In practice this means a runtime today reaches the not-verified-and-that-is-not-a-failure state, and no conforming bundle is published for a third party to fetch. This is stated plainly rather than left to be inferred from an empty registry tag. - **Enforcement: opt-in**, per Enforcement posture, with the cutover conditions listed there. - **Kernel binding: absent in v1**, closed in v2. ## Relationship to the v2 wire form A successor exists in the reference implementation at `https://getprobity.dev/predicate/v2/launch-chain`. Its four measurement members are unchanged in name and meaning; what changes is that the statement becomes an in-toto Statement v1 whose **subject is the kernel index digest**, canonicalized under RFC 8785 and wrapped in the DSSE envelope this standard specifies elsewhere. That makes the launch chain and the kernel it belongs to one cryptographic fact instead of a registry naming convention, which is the open weakness this document records against itself under Subject. The v2 form also retires the `<`, `>`, `&` prohibition above, because RFC 8785 fixes the escaping and the producer's canonicalizer normalizes it, leaving the divergence no place to occur. Under the v1 byte layout the prohibition stands. This document remains normative for the v1 URI, and a verifier presented with a v1 bundle MUST apply the v1 rules rather than in-toto Statement parsing. A consumer dispatches on the full `predicateType` string, so the two never collide. ## Related standards - **[DSSE](https://github.com/secure-systems-lab/dsse)** - PARTIALLY ALIGNED. PAE and its domain separation are used verbatim; the envelope object is not. - **[in-toto attestation](https://github.com/in-toto/attestation)** - NOT USED in v1; the v2 successor is the in-toto-shaped form. - **[Sigstore / cosign](https://docs.sigstore.dev/)** - PARTIALLY ALIGNED. Static-key `sign-blob` / `verify-blob` over the PAE bytes, without Fulcio certificates and without Rekor upload. - **[dm-verity](https://www.kernel.org/doc/html/latest/admin-guide/device-mapper/verity.html)** - the root-hash form `rootfsVerityRootHashes` carries is `veritysetup`'s, bare hex, fixed salt. - **[kernel-substrate](kernel-substrate.md)** - the anchor this predicate extends. - **[signing-envelope.md](signing-envelope.md)** - the DSSE / in-toto envelope the rest of this standard uses, and which this v1 predicate deliberately does not. ## Versioning This is the launch-chain v1 predicate and its member set is closed. Verification is byte-equality over a canonical pre-image, so there is no additive-optional-field path: adding, renaming, removing, or reordering a member - including changing the element order or the element form of `rootfsVerityRootHashes` - changes the signed bytes and is breaking. Any such change is a new major version URI. The additive-field forward-compatibility rule in the standard's [README](README.md) applies to predicates carried inside the in-toto envelope and does not apply to this v1 wire form. ============================================================================== # DOCUMENT: v2/launch-chain # CANONICAL: https://getprobity.dev/predicate/v2/launch-chain # MARKDOWN: https://getprobity.dev/predicate/v2/launch-chain.md ============================================================================== # Probity Launch-Chain Attestation, v2 **predicateType:** `https://getprobity.dev/predicate/v2/launch-chain` A launch-chain attestation is a signed, offline-verifiable statement about the **rest of the boot pre-image**: everything a microVM is launched with beyond the kernel image itself. It binds the initrd digest, the exact kernel command line, and the ordered set of dm-verity root hashes the rootfs is mounted under, **to the kernel those measurements belong to**. The [kernel-substrate](kernel-substrate.md) attestation says which kernel bytes are approved; this one says what the kernel is approved to be *started with*, which is where a substituted command line or a swapped rootfs image would otherwise pass unnoticed. **The kernel binding is what v2 is for.** The [v1 form](../v1/launch-chain.md) carries no subject at all: its measurements sit in a bespoke payload and the join to a kernel is made at the distribution layer, by publishing the bundle at a registry tag derived from the kernel digest. That binding rests on a naming convention a registry operator controls rather than on a cryptographic fact, and the v1 document records it against itself as the predicate's principal known weakness. In v2 the **kernel index digest is the in-toto Statement subject**, so a launch chain and the kernel it belongs to are one cryptographic fact. Nobody controls what is inside a signature. The four measurement members are unchanged in name and meaning from v1. Everything around them changes: the statement becomes an [in-toto Statement v1](https://github.com/in-toto/attestation/blob/main/spec/v1/statement.md) canonicalized under [RFC 8785](https://www.rfc-editor.org/rfc/rfc8785) and carried in the DSSE envelope specified in [signing-envelope.md](../v1/signing-envelope.md), and verification stops being a byte-equality reconstruction and becomes verify-then-read. The v1 document normatively fixes a wire form that stops being true under those changes, and changing a published wire form in place is exactly the silent in-place change this standard forbids, so v2 is a new URI even for the members that did not move. The reference implementation is the engine's attestation package (statement construction, envelope construction, verification and decoding), its producer-side minting sibling, and the `probity-attest` command, which is the only producer of these bytes. 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). ## Subject The subject is the **kernel image the launch chain belongs to, by OCI manifest-index digest** - not any of the predicate's own measurements. It is byte-for-byte the same subject shape the [kernel-substrate](kernel-substrate.md) statement carries, and for the same kernel it is the same value, which is what lets a consumer join the two statements without trusting an address. ```json "subject": [{"digest": {"sha256": "<64 lowercase hex>"}, "name": "guest-kernel"}] ``` - The digest value is **bare lowercase hex with no `sha256:` prefix**, the in-toto convention. Note the deliberate asymmetry with the predicate below, which keeps the OCI `sha256:`-prefixed form for `initrdDigest` and the bare `veritysetup` form for the verity hashes. Conflating the spellings is the most likely implementation error here; normalize in one place. - Uppercase hex is rejected rather than folded. Two spellings of one digest would make two distinct statements compare equal. - `name` is REQUIRED and non-empty, conventionally `guest-kernel`. It is inside the signed bytes and so must be deterministic, but **a verifier binds on the digest map and MUST NOT bind on the name**. - The array MUST be non-empty. A consumer that has verified this statement MAY now assert that these measurements were signed *for this kernel*. Under v1 it could not, and the v1 document says so explicitly. ## Statement and canonicalization The signed payload is the RFC 8785 canonical serialization of the Statement. A real example, from the committed conformance vectors, with a verity command line and a two-element ordered verity array: ```json {"_type":"https://in-toto.io/Statement/v1","predicate":{"initrdDigest":"sha256:e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855","kernelCmdline":"console=ttyS0 reboot=k panic=1 dm-mod.create=\"root,,,ro,0 100 verity 1 /dev/vda /dev/vda 4096 4096 100 1 sha256 a599...93a4 0000...0000\" root=/dev/dm-0 init=/init","rootfsVerityRootHashes":["a599...93a4","bbbb...bbbb"],"version":"6.1.137"},"predicateType":"https://getprobity.dev/predicate/v2/launch-chain","subject":[{"digest":{"sha256":"04d1...9c8d"},"name":"guest-kernel"}]} ``` - **Member order is RFC 8785 order, which is sorted by member name**, at the Statement level (`_type`, `predicate`, `predicateType`, `subject`) and inside the predicate (`initrdDigest`, `kernelCmdline`, `rootfsVerityRootHashes`, `version`). The v1 form's member order was the producer's struct declaration order and *was* the format; here nothing depends on a struct's field order, and `predicateType` moves from first to third. - There is no insignificant whitespace, no indentation, and no trailing newline. - **`rootfsVerityRootHashes` is a JSON array and its element order is significant.** RFC 8785 sorts object members; it does not reorder array elements, and it must not. See Field semantics. - A producer MUST emit RFC 8785 canonical bytes. A verifier MUST NOT re-canonicalize the payload as part of verification and MUST NOT reconstruct the payload in order to compare it; see Verification. **The v1 prohibition on `<`, `>` and `&` in `kernelCmdline` is retired, and this predicate is why it mattered.** Go's `encoding/json` rewrites those three characters as the six-character sequences `\u003c`, `\u003e` and `\u0026` while other encoders emit them literally, so two conforming-looking producers disagreed on the signed bytes for the same command line and byte-equality verification failed with no diagnostic pointing at the cause. The v1 documents handle that by forbidding the characters - a producer rule standing in for a fix. It was a live hazard rather than a theoretical one precisely here: kernel-substrate's value slots are a URI, a digest and a version, none of which can contain those characters, while `kernelCmdline` is an unconstrained string. RFC 8785 decodes the escapes and re-emits the raw characters, both encoders land on the same bytes, and the prohibition is removed rather than restated. The conformance vectors carry a command line containing all three characters for exactly this reason: a rail that reproduces that vector has reproduced the fix. ### Parse rules a verifier MUST apply to the verified bytes These are checks on the *already-verified* payload, and each exists so two verifiers cannot disagree about what a signed statement says: - **Duplicate object members are rejected**, anywhere in the document. Go's `encoding/json` takes the last occurrence of a repeated member and a strict parser elsewhere takes the first. - **Trailing data after the top-level value is rejected**, and input holding no complete top-level value is rejected. - **Nesting is capped at 128 levels**, the same bound the producer's canonicalizer applies, so a payload a verifier accepts is one the producer could have produced. A conforming statement nests three levels. - **Unknown members are ignored, never rejected.** See Versioning. ## Field semantics The predicate body carries the four measurements: ```json {"initrdDigest":"sha256:<64-hex>","kernelCmdline":"","rootfsVerityRootHashes":["<64-hex>"],"version":""} ``` - **`initrdDigest`** (REQUIRED, string) - the digest of the initrd, in **OCI `sha256:<64 lowercase hex>` form**, prefix included. The prefix is kept here, unlike in the subject, because this is a predicate value with three distinguished cases and **conflating any two of them is the single most likely implementation error in this predicate**: - a real digest - the boot loads that initrd; - the **empty-input digest** `sha256:e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855`, which is SHA-256 over zero bytes and is the canonical way to say **the boot loads no initrd at all**. A Firecracker direct kernel boot has no initramfs, so there is no file to hash; this value is a real, promoted measurement, not an absence marker; - the **all-zero placeholder** `sha256:0000...0000` (64 zeros), which means **not yet promoted** - the publisher has not committed a real measurement. This is the opposite of the case above: one is a fact, the other is the absence of one. A producer MUST reject a value that does not carry the `sha256:` prefix and MUST reject one that is not 64 lowercase hex characters after it. The empty string is not a legal value. - **`kernelCmdline`** (REQUIRED, string, non-empty) - the exact kernel command line the boot uses, verbatim and complete. Comparison against it is an **exact string match**, never a prefix, substring, or containment check: the point of binding it is that an added or altered parameter (one that disables a guard, for instance) must not pass. In the reference deployment the per-sandbox boot data deliberately does not travel on the command line, which is what makes an exact match a workable requirement rather than a permanently failing one. A producer SHOULD take this value from a **file rather than a command-line argument**. A kernel command line carries double quotes (`dm-mod.create="..."`) and passing it through a shell argument is the class of quoting accident that produced the encoder divergence above; reading it from a file means the bytes a human wrote are the bytes that get signed. The reference producer strips at most one trailing newline (because the ordinary way to write such a file ends in one and a kernel command line never does) and **rejects** any other newline, a NUL byte, and invalid UTF-8. Stripping more would let trimming hide content; accepting an embedded newline would sign a value no bootloader can pass; and Go's JSON encoder silently substitutes U+FFFD for invalid UTF-8, so accepting it would sign a value that is not the one on disk. - **`rootfsVerityRootHashes`** (REQUIRED, array of strings, non-empty) - the dm-verity root hashes of the rootfs, one per mounted verity target, each as **bare 64 lowercase hex with no `sha256:` prefix** (the `veritysetup` root-hash form, deliberately not the OCI form `initrdDigest` uses). - **Order is significant and is compared as an ordered sequence, never as set membership.** The position of a hash is which target it is, so two statements listing the same hashes in different orders bind different things and MUST NOT compare equal. - An element MAY be the all-zero root hash `0000000000000000000000000000000000000000000000000000000000000000`, meaning that measurement is not yet promoted. - An empty array is not conforming: it would silently bind no rootfs integrity at all. A producer MUST refuse one. - A hash pinned here MUST be produced with a **fixed salt** (`veritysetup format --salt=`). `veritysetup` randomizes the salt per invocation by default, and a random salt makes the root hash non-reproducible, so a defaulted salt yields a value nothing can ever re-derive. - **Which targets appear here is a deployment decision, not a rule of this format.** In the reference deployment the array carries the fixed substrate root only - the element-0 init-root, identical for every sandbox - while per-sandbox workload overlay-lower hashes travel on a separate signed channel and are verified inside the guest. A consumer therefore MUST NOT read this array as a complete inventory of every verity target a running guest mounts; it is the set the publisher pinned. - **`version`** (REQUIRED, string, non-empty after trimming) - the kernel version the launch chain corresponds to, tying this statement to the same release as the [kernel-substrate](kernel-substrate.md) attestation. As there, **it is a label under signature, not a derived fact**; nothing verifies that the named version is the kernel these measurements were gathered against. In v2 the version is no longer load-bearing for the join to a kernel, because the subject does that cryptographically. A producer validates **form only** and deliberately does not judge meaning. An all-zero placeholder is a well-formed value that a pin legitimately carries before promotion; deciding whether an unpromoted pin may be enforced against is the enforcing consumer's decision, not the producer's. A producer MAY add optional members and a verifier MUST ignore members it does not recognize. The v1 form could not offer that: any additional member changed the signed bytes and broke byte-equality verification outright. ## Signature construction The signature is a raw ed25519 signature over the DSSE **Pre-Authentication Encoding (PAE)** of the canonical Statement bytes, never over the bare payload. ``` PAE = "DSSEv1 " || LEN(payloadType) || " " || payloadType || " " || LEN(body) || " " || body ``` where `LEN` is the ASCII decimal byte length, `body` is the canonical Statement above, and ``` payloadType = "application/vnd.in-toto+json" (28 bytes) ``` so a conforming pre-image begins `DSSEv1 28 application/vnd.in-toto+json `. **This is the same payload type the [kernel-substrate](kernel-substrate.md) statement is signed under, which is a deliberate change from v1 and moves a security property.** The v1 forms used distinct private media types (`application/vnd.probity.launch-chain.v1+json`, 44 bytes, here; `application/vnd.probity.kernel-attestation.v1+json`, 50 bytes, there), so a signature minted for one class could never be replayed as the other because the PAE differed. In v2 the separation between the two substrate classes lives in `predicateType`, and **a verifier MUST check it as a field**. Under byte-equality it never had to be examined, because the type string sat inside bytes that were compared whole. A verifier that does not demand a predicate type accepts a kernel approval where it wanted a launch chain. Separation from every *other* Probity artifact class is unaffected and still lives in the payload type: a catch record is signed under its own payload type, a Statement under this one, and PAE's length prefixes make the pair injective. A verifier MUST reconstruct the PAE with the payload-type **constant above**, never with the value read off the wire, even after confirming the two agree. ## Envelope format and distribution Identical to [kernel-substrate](kernel-substrate.md): the DSSE envelope of [signing-envelope.md](../v1/signing-envelope.md), with a mandatory `keyid` in every signature entry equal to the lowercase-hex SHA-256 of the RFC 7638 JWK thumbprint input for the ed25519 public key. ```json { "payload": "", "payloadType": "application/vnd.in-toto+json", "signatures": [{"keyid": "<64 lowercase hex>", "sig": ""}] } ``` An implementation therefore shares one envelope code path with the companion predicate and differs only in the predicate body, the predicate type it demands, and the binding comparison it runs. The `payloadType` assertion runs before any signature work, because it is the cheapest place to catch a cross-class replay. The `keyid` is a selector and creates no trust: it is excluded from the PAE and therefore unauthenticated, a verifier resolves it against configured anchors and independently re-derives it from the resolved key, and a matching keyid is never evidence of anything on its own. A consumer MUST cap the envelope bytes it will consider; the reference implementation caps at **1 MiB**, matching the cap its v1 read paths already applied. **Distribution for the v2 form is not yet fixed by the reference implementation.** The v1 launch-chain bundle has a specified address (a `launch-chain-` tag, deliberately distinct from the kernel-substrate tag prefix so the two classes never collide) precisely because that address was the *only* thing connecting a v1 launch chain to its kernel. In v2 the address is no longer load-bearing for that join - the subject is - so a publisher is free to choose one, and this document specifies the artifact rather than its location. A publisher SHOULD still make the envelope resolvable from the pinned kernel digest alone, and SHOULD keep it under the same authentication as the image it describes. A locally cached envelope MUST be keyed by the kernel digest it binds, so a republished kernel is a cache miss by construction rather than a silent reuse of the previous measurements. ## Trust root Identical to [kernel-substrate](kernel-substrate.md): a configured set of ed25519 public keys indexed by the keyid derived from each key, so a key can never be resolved under an id it does not hash to. The reference implementation embeds one key in PKIX/PEM form in the verifying binary (`pkg/kernel/keys/probity.pub`) with the private half held out of band as a CI secret, but nothing in the format names or assumes a particular key, and an empty anchor set MUST be refused at construction rather than silently rejecting every envelope. Multiple signature entries are the rotation path: an entry whose keyid does not resolve is skipped rather than fatal, as is an entry whose `sig` is not decodable base64. "No configured anchor was named" and "an anchor was named but nothing verified" MUST be distinct outcomes; only the second is evidence of tampering. The key is cosign-compatible, so a third party verifies with no Probity code: ``` cosign verify-blob --key probity.pub --signature ``` over the PAE bytes reconstructed per the construction above. The producer signs the same file with `cosign sign-blob --yes --key env:// --tlog-upload=false`. `--tlog-upload=false` is load-bearing: no entry is written to a public transparency log, so the attestation stays as private as the artifact and there is no third-party inclusion proof to appeal to. ## Verification (third-party, offline) The procedure is **verify-then-read**. A v1 verifier reconstructs the signed bytes from measurements it already holds and compares them whole, so binding happens before the signature check; a v2 verifier checks the signature and then reads the bytes that verified, and binding is a mandatory comparison afterwards. What must never happen, under either ordering, is a path that returns "verified" while an attested value differs from the value the verifier pinned. 1. **Cap and parse the envelope.** 2. **Assert `payloadType`** equals `application/vnd.in-toto+json`, before any signature work. 3. **Assert `signatures` is non-empty and every entry carries a `keyid`.** 4. **Base64-decode `payload`.** 5. **Resolve each entry's `keyid` against the configured anchors** and verify the signature as raw ed25519 over `PAE("application/vnd.in-toto+json", payload)`, reconstructed from the constant. 6. **Parse the decoded bytes** under the parse rules above and assert `_type` is `https://in-toto.io/Statement/v1`, `predicateType` is exactly `https://getprobity.dev/predicate/v2/launch-chain`, `subject` is non-empty, and `predicate` is present. 7. **Bind, and this step is MANDATORY and total.** Compare, against values the verifier holds independently: - the **subject digest** against the kernel index digest it intends to trust; and - **all four predicate members**, with `rootfsVerityRootHashes` compared as an **ordered sequence**. There is no partial mode worth having. A gate that verifies a signature without binding what the signature says is a vacuous check; the reference producer's own history contains a stdin-starved pipeline that published a zero-byte payload which signed and gate-verified perfectly. Report a binding mismatch with a sentinel distinct from a signature failure, and name the member that differs, so a failing gate says which measurement moved rather than only that something did. No network access and no live execution environment is required once the envelope and the key are in hand. ### Verifying against a live boot An enforcing runtime performs the same procedure with one addition that carries most of the value: **it binds against the measurements the boot is about to actually use, not against the values it pinned.** The statement binds the publisher's promotion, so a comparison against a live reconstruction succeeds only when live equals pin equals attested. A caller that compares the attested predicate to its own pin has proved only that it agrees with itself. An implementation SHOULD also make the not-verified state unforgeable by construction, so that a dropped error or an unassigned result can never read as verified; only a present envelope that verified and then bound over the live measurements may yield a verified value. ### CANON-EQ A payload can be signed and yet not be RFC 8785 canonical, if a producer is defective. Asserting that the verified payload is already canonical is called CANON-EQ, and where it runs is deliberately asymmetric: a **boot-critical verifier does not run it**, because enforcing it there would import a JSON canonicalizer and its unbounded recursive-descent parser into a boot path to defend against a producer defect; a **producer, a CI promotion gate, an auditor, or any non-boot-critical third-party verifier SHOULD run it**, and the reference `probity-attest verify` does. The honest cost is that a producer bug emitting non-canonical-but-signed bytes would reach a boot path; the mitigations are that the promotion gate will not publish it and that the mandatory binding contains what it could do. ## Enforcement posture **The v2 form is not on any enforcement path in the reference implementation.** Nothing in the boot path calls the v2 verifier; the v1 bespoke bundle path is untouched and is still the only thing that runs there. - **No environment variable governs the v2 form.** `PROBITY_REQUIRE_LAUNCH_CHAIN` and `PROBITY_DEV_ALLOW_UNATTESTED_KERNEL` govern the v1 kernel package alone and have no effect on a v2 statement. This document names no others, because none exist in current source. - **The v1 posture is unchanged and remains live**: the v1 launch-chain gate is an explicit named opt-in, off by default, while a present-but-malformed or invalidly-signed v1 bundle is a hard failure whether or not the gate is on. See [the v1 document](../v1/launch-chain.md) for the disposition table and for the conditions that must be met before that gate can default on. Two of those conditions are properties of the deployment rather than of either format and still apply: **no launch-chain envelope is published** by any workflow, and **verity boot is opt-in**, so a default non-verity boot gathers a different command line and an empty verity set that a live comparison against a promoted pin would correctly reject. A gate turned on before a producer exists fails every legitimate boot rather than catching an illegitimate one. One disposition is worth restating as a rule of this format rather than of a deployment: **an enforcing consumer SHOULD refuse to enforce against an all-zero placeholder pin.** Refusing looks like the weaker choice and is not. Enforcing against an unpromoted pin either bricks a real boot or "passes" a gate that binds nothing, and a gate that passes while binding nothing is worse than an absent gate, because it reads as evidence. ## What this predicate does NOT assert (coverage) The coverage statement is part of the contract. A conforming launch-chain attestation says nothing about any of the following: - **That the named version is accurate.** `version` is an unverified label. - **Every verity target a guest mounts.** The array is what the publisher pinned - in the reference deployment the fixed substrate root only. Per-sandbox workload measurements travel elsewhere. - **That dm-verity was actually enabled for a given boot.** The statement binds root hashes; whether a particular launch used them is an enforcing runtime's live comparison, not a property of the envelope. - **That the kernel bytes named by the subject are approved.** The subject binds *which* kernel these measurements belong to; whether that kernel is approved is the [kernel-substrate](kernel-substrate.md) statement, and a consumer that needs both MUST verify both. The shared subject digest is what lets it join them without trusting an address. - **Any host-side confinement.** A host seccomp filter is not bound by this predicate. Under v2 it could be added as an optional member without a new major version, which the v1 form could not do; until a producer emits one, a consumer MUST NOT infer it. - **That the command line is safe, or that the verity root hashes correspond to any particular filesystem image.** - **Build provenance, transparency-log inclusion, revocation, or key legitimacy** - as for [kernel-substrate](kernel-substrate.md), and for the same reasons. - **That the payload was canonical**, unless the verifier ran CANON-EQ. ## What a verifier can and cannot independently check (honesty) - A verifier CAN confirm the envelope is authentic under a configured anchor, which anchor admitted it, that the payload is an in-toto Statement v1 of exactly this predicate type, **which kernel index digest the measurements are bound to**, and that the initrd digest, command line, ordered verity root set and version are exactly the ones it intended - all offline, from the envelope and the public key alone. An enforcing runtime CAN additionally confirm that the boot it is about to perform uses those same measurements. - A verifier CANNOT confirm, from the statement alone, that the command line is safe, that the verity root hashes correspond to any particular filesystem image, that the publisher's promotion decision was sound, that the kernel named by the subject is itself approved (that is the companion statement), or that a more recent promotion has superseded this one. ## Status and maturity - **Format, canonicalization, signing construction, and verification: implemented.** One implementation produces the canonical bytes and one verifies them, from the same packages, so a producer and a consumer cannot diverge by construction. Committed cross-language conformance vectors carry the public key, the payload type, the canonical bytes, the PAE pre-image, the signature and the assembled envelope for a realistic verity command line, for a command line exercising the retired escaping hazard, and for a statement with unknown members. - **Producer: a command exists; no CI step runs it.** `probity-attest statement launch-chain` builds the statement, `pae` emits the pre-image for `cosign sign-blob`, and `envelope` assembles the envelope from the committed public key. No workflow mints or publishes a v2 launch-chain envelope, and no distribution address is defined. As under v1, no conforming launch-chain artifact is published for a third party to fetch. - **Consumer: not wired.** No boot path calls the v2 verifier. - **Kernel binding: closed.** This is the v1 weakness this predicate exists to fix, and it is fixed in the format and in the producer, not merely proposed. ## Relationship to the v1 wire form [The v1 predicate](../v1/launch-chain.md) remains normative for the v1 URI. A verifier presented with a v1 bundle MUST apply the v1 rules - reconstruct the bespoke five-member payload in declaration order and compare bytes - rather than in-toto Statement parsing. A consumer dispatches on the full `predicateType` string, so the two never collide, and a v1 bundle carries no `payloadType` member for a v2 verifier to accept in the first place. Every difference, in one place: | | v1 | v2 | |---|---|---| | Kernel binding | none in the signed bytes; a registry tag convention | the kernel index digest is the Statement subject | | Container | bespoke `{payload, signature}` JSON object | DSSE envelope `{payload, payloadType, signatures[]}` | | On-wire payload type | absent | `application/vnd.in-toto+json`, checked before any signature work | | Signed payload | bespoke 5-member object, no `_type`, no subject | in-toto Statement v1 | | Predicate members | the same four, plus `predicateType` in the same object | the same four, in a `predicate` object | | Canonicalization | producer's field declaration order | RFC 8785, sorted member names | | `predicateType` position | first | third (`_type`, `predicate`, `predicateType`, `subject`) | | Domain separation, class to class | private per-class payload type (44 bytes) | shared in-toto payload type; `predicateType` checked as a field | | Key identity on the wire | none | mandatory `keyid`, RFC 7638 thumbprint, selector only | | Verification order | reconstruct, compare bytes, then check signature | check signature, then read, then bind | | Binding scope | one byte comparison covers everything | subject digest plus all four members, verity array ordered | | Unknown members | forbidden; any extra member breaks verification | MUST be ignored | | Duplicate members | unreachable (bytes compared whole) | explicitly rejected | | `<`, `>`, `&` in `kernelCmdline` | prohibited by producer rule | permitted; RFC 8785 fixes the escaping | | Multiple signers | not representable | permitted; unresolved keyids skipped | | Untrusted-signer vs forged | indistinguishable | distinct failures | | Enforcement | opt-in via a named environment variable | not wired to any enforcement path | | Published artifact | none | none | ## Related standards - **[in-toto attestation](https://github.com/in-toto/attestation)** - USED. The payload is an in-toto Statement v1 and the subject convention is in-toto's; making the kernel digest the subject is what closes the v1 binding gap. - **[DSSE](https://github.com/secure-systems-lab/dsse)** - USED, envelope and PAE both. - **[RFC 8785 (JCS)](https://www.rfc-editor.org/rfc/rfc8785)** - USED as the canonicalization, which is what retires the v1 escaping prohibition on `kernelCmdline`. - **[RFC 7638 (JWK thumbprint)](https://www.rfc-editor.org/rfc/rfc7638)** - USED as the keyid derivation; see [signing-envelope.md](../v1/signing-envelope.md). - **[Sigstore / cosign](https://docs.sigstore.dev/)** - PARTIALLY ALIGNED. Static-key `sign-blob` / `verify-blob` over the PAE bytes, without Fulcio certificates and without transparency-log upload. - **[dm-verity](https://www.kernel.org/doc/html/latest/admin-guide/device-mapper/verity.html)** - the root-hash form `rootfsVerityRootHashes` carries is `veritysetup`'s: bare hex, fixed salt. - **[kernel-substrate](kernel-substrate.md)** - the anchor this predicate extends, and in v2 the statement it shares a subject with. - **[signing-envelope.md](../v1/signing-envelope.md)** - the envelope this predicate rides, and which its v1 form deliberately did not. ## Versioning The versioning rules in the standard's [README](../v1/README.md) apply to this predicate in full, and they could not apply to its v1 form: - **Major version in the URI.** A consumer dispatches on the full `predicateType` string and MUST reject a type it does not recognize. - **Additive-optional members are a minor change and stay within the major version.** A producer MAY add optional members and a verifier MUST ignore what it does not recognize. This works because verification never rebuilds the bytes, so a member the verifier does not know about is a member it does not read. A host seccomp filter digest is the obvious candidate and would not require a v3. - **Rename, removal, or any semantic change of an existing member is a new major version URI** - and that includes changing the element order or the element form of `rootfsVerityRootHashes`, since the order is which target is which. ============================================================================== # DOCUMENT: v1/open-predicate-standardization # CANONICAL: https://getprobity.dev/predicate/v1/open-predicate-standardization # MARKDOWN: https://getprobity.dev/predicate/v1/open-predicate-standardization.md ============================================================================== # Open-Predicate Standardization A short roadmap note: how the Probity predicate types — `security-verdict` and `adversarial-execution-evidence` above all — become an **open standard the incumbent admission controllers recognize natively**, so an enterprise's existing Kyverno / sigstore policy-controller can demand and verify a Probity verdict without a single Probity-authored component in the trust path. This is the demand-pull other half of the admission bundle in [`deploy/admission/`](../../deploy/admission/README.md): that bundle shows the verdict is *already* enforceable today by an incumbent pointed at our public key; this note is about making the predicate type a recognized member of the broader in-toto / SLSA predicate ecosystem so recognition is the default, not a per-deployment configuration. ## Why standardize the predicate type What makes this durable is that the value lives in the **verifiable verdict an existing admission controller can consume directly**, not in a gate we ship. That value grows as the predicate type becomes something a tool recognizes out of the box: - An admission controller, registry, or CI gate keys off `predicateType`. The more widely `https://in-toto.io/attestation/adversarial-execution-evidence/v0.7` is a *known* predicate type, the less configuration a consumer needs and the higher the cost of a competing, incompatible verdict schema. - A stable, published predicate type with a published schema and a conformance suite is exactly the shape the in-toto attestation framework and the SLSA predicate conventions are built to register and route. ## What is already in place | Requirement for a recognized open predicate | Status | Where | |---|---|---| | A **stable predicateType URL** under a controlled namespace | DONE | `https://getprobity.dev/predicate/v1/` for the Probity-namespace types; the flagship evidence predicate is proposed under the in-toto namespace as `https://in-toto.io/attestation/adversarial-execution-evidence/v0.7` | | A **published predicate schema** (machine-readable) | DONE | `spec/schemas/v1/adversarial-execution-evidence.schema.json` (draft 2020-12), the on-wire SSOT; prose in `spec/v1/adversarial-execution-evidence.md` | | A **predicate-agnostic signing envelope** | DONE | `spec/v1/signing-envelope.md` — DSSE PAEv1 + ed25519 + RFC-7638 keyid | | A **conformance suite** any tool can run offline | DONE | `spec/v1/conformance.json` + signed bundles; the `@probity/verify` package carries it (`packages/verify/`) | | **Reject vectors** proving fail-closed behavior | DONE | `packages/verify/vectors/aee-v06/` (bare v0.7 in-toto statements — unknown predicateType, stripped catchPolicy, ...; inline-signed with an ephemeral key for the crypto rails) | | A **binding chokepoint** mirrored across runtimes | DONE | `bindings.ts` (TS) + the Python verdict_bundle verifier + `deploy/admission/rego/probity_admission.rego` (rego) | | A **discoverable verification-key endpoint** | SHIPPED (2026-06-29, DEC-20260630-001) | `https://getprobity.dev/.well-known/verification-keys` — served from `website/public/.well-known/verification-keys.json`; a `keyid`-resolving verifier fetches the public key (`427a1092…` → `probity-mcp.pub`) instead of pinning it inline, which is what an incumbent's "trusted issuer" configuration wants | | An **admission-freshness field** (`issuedAt`) | SPECIFIED (2026-07-06) | OPTIONAL top-level `issuedAt` (RFC3339 UTC) in `spec/schemas/v1/adversarial-execution-evidence.schema.json` + `spec/v1/adversarial-execution-evidence.md`; the Kyverno freshness policy in `deploy/admission/kyverno/` gates on it (deny if older than the consumer's threshold, e.g. 168h) and fails closed while it is absent — the threshold is the consumer's policy, not part of the standard; producer emission is the remaining code change | The format is therefore already an open standard in substance: vendor-neutral, offline-verifiable against a *configurable* trust anchor (the envelope explicitly forbids hardcoding `probity.pub` as the only acceptable key), with a published schema and a runnable conformance suite. ## What native recognition still needs Two items remain open, both process (the discoverable key endpoint and the `issuedAt` freshness field, formerly listed here, have shipped / been specified — see the table above; producer emission of `issuedAt` is the one remaining code change). 1. **Submit the predicate type to the in-toto / SLSA predicate registry.** in-toto maintains a list of known predicate types; SLSA references predicate conventions. `adversarial-execution-evidence` is authored FOR that namespace (`https://in-toto.io/attestation/adversarial-execution-evidence/v0.7`), positioned as the active-stimulus sibling to the passive `runtime-trace` family: bounded adversarial stimulus, per-attack-to-catch attribution, and a cryptographically committed coverage bound, with a fused `result` that is a deterministic, severity-independent, offline-recomputable reduction of the enclosed evidence (the in-toto `test-result` precedent) — deliberately NOT an SVR-class delegated policy decision. Register it (with links to the schema + conformance suite) so registry-aware tooling resolves the type without per-deployment config; the envelope IS DSSE with PAEv1. 2. **A neutral-governance signal.** Broad recognition benefits from genuine multi-vendor governance: a public `CHANGELOG`/versioning policy (the two-axis envelope-vs-predicate-schema split is already documented), an explicit deprecation policy, and an invitation for other producers to emit verdicts under their own keys (which the configurable trust root already permits). The format does not name or assume `probity.pub`; that neutrality is the credible-open-standard hook. ## Honesty / scope - Standardizing the *predicate* does not standardize the *runtime*. The runtime that produces a sound verdict (the substrate, the corpus, the gate engine) stays proprietary; the open surface is the verdict shape and its offline-verify contract. A third party can verify a verdict's integrity, provenance, subject binding, and the offline-checkable bindings — it cannot, from the verdict alone, re-derive the producer or confirm the assessment taxonomy is complete (the verdict specs say this plainly). - Registry submission is an external-party action with its own timeline; it is named here as roadmap, not as shipped. Both remaining items (registry filing, governance) are process; the filing mechanics and submission draft are prepared in `research/91-standards-filing-2026-07-06/`. Producer emission of the now-specified `issuedAt` field is a one-field code change. ============================================================================== # DOCUMENT: v1/adversarial-execution-evidence-failure-codes # CANONICAL: https://getprobity.dev/predicate/v1/adversarial-execution-evidence-failure-codes # MARKDOWN: https://getprobity.dev/predicate/v1/adversarial-execution-evidence-failure-codes.md ============================================================================== # Adversarial Execution Evidence — Verifier Failure Codes > ## THIS DOCUMENT IS NOT THE SPECIFICATION, AND IT IS NOT A RESTATEMENT OF ONE EITHER > > [`adversarial-execution-evidence.md`](adversarial-execution-evidence.md) in this same directory is a > **local restatement** of the upstream authority (`spec/predicates/adversarial-execution-evidence.md` > in the `in-toto/attestation` fork, mirrored in the `aee-conformance` repository), checked against that > authority by `scripts/lint_spec_restatement.py` and listed > in that script's `_DOCUMENTS` registry. > > **This document is a different kind of thing, and it is deliberately NOT in that registry.** It > documents `packages/verify/src/evidence.ts`'s `Code` union — the exhaustive set of failure codes this > repository's TypeScript reference verifier can emit — grounded in the verifier's own source, not in > the upstream specification's prose. The upstream authority does not enumerate an implementation's > internal code strings at all (the spec constrains observable behavior, not a particular verifier's > diagnostic vocabulary), so there is no authority text for a restatement gate to check this document > against. **This file's authority is `evidence.ts` itself**, and the thing that keeps it honest is > `scripts/lint_failure_codes.py`: every code documented here is > cross-checked against the live `Code` union (bidirectionally — nothing may exist on only one side) and > every citation below is cross-checked against the live conformance corpus, not trusted as prose. > > Do not cite this file as normative, and do not treat it as a second implementation of the spec's > failure taxonomy. It is a reference glossary over one file in this codebase. This verifier's `Code` union (`packages/verify/src/evidence.ts:140-220`) has **67 members**. `verifyStatement` (`evidence.ts:2542`) runs a pinned sequence — GATE 0 (statement well-formedness) → GATE 1 (coverage validity, both the per-row pipeline and the statement-level requirements v0.7 adds) → the recompute-equality check → GATE 2 (evidence tier) → the consumer-policy anchor step — and stops at the first gate that emits a non-empty code list. Every code below is grouped the way `evidence.ts`'s own `Code` union comments already group it (lines 141, 168, 176, 197, 205, 211, 213), because that grouping is the verifier's own structure, not one invented for this document. **63 of the 67 codes** are exercised by at least one vector in the [`aee-conformance`](https://github.com/astrogilda/aee-conformance) conformance corpus. Each is cited below by the **condition** its forcing vector exercises — an `aee-c-N` id from that suite's condition registry, naming a requirement of the specification — and the lint script resolves that condition through the corpus manifest to a vector whose `expected.codes` carries the code, so a citation is verified rather than merely present. **4 codes are structurally exempt** from vector coverage; see [Exemptions](#exemptions) for why, and each of the four entries below repeats the specific reason. **Why a condition and not a vector name.** These entries cited vectors by `id` until 2026-09-02, when the corpus renamed every vector to a content address for the express purpose of stopping identifiers carrying meaning. Sixty-three citations here went dangling at once. Substituting the new addresses would have produced a specification citing sixty-three opaque digests, unreadable to a person and broken again by the next regeneration. A condition survives that change and every change like it: the rename altered every `id` and every `file` in the manifest and left the `(kind, conditions, expected)` content of all 272 vectors identical, vector for vector. It also says more than a filename ever did. `bad-729-duplicate-attackid-rows` asserted in its own name, checked by nothing, what the vector was for; `aee-c-90` is a requirement, and the corpus is what says which vectors force it. **Corpus pin.** Every citation below was checked against the conformance corpus this repository vendors at `packages/verify/vectors/aee-v06/` — release `v0.8.0`, source commit `0c4e27ec4f28b03d9688885bb5a00c6e96334d1e`, manifest digest `eb24532be577ee6a936410e6850482847cecac07147aa3c76d737fc730d4bf2b` — which carries 272 vectors: 61 `accept`, 209 `reject`, 2 `indeterminate`. This paragraph is not decoration and it is not a snapshot that rots. `scripts/lint_failure_codes.py` reads it, holds the vendored copy to it on all three axes, and REFUSES to check a single citation below when they disagree, naming what was expected and what is there. Until 2026-09-02 this document was instead graded against whatever the `aee-conformance` sibling checkout had most recently committed, and a local rename of every vector in that checkout invalidated roughly forty citations in this published document with no version boundary anywhere in between. A corpus that moves under a specification is a corpus the specification never agreed to; moving this pin is now a deliberate act, taken by somebody who has re-read the citations against the new corpus. ## How to read an entry Each code below gets one subsection: a plain-language statement of the condition that makes the verifier append it to a report's `codes` array, grounded in the actual call site(s) in `evidence.ts` (cited as `evidence.ts:LINE`), followed by a `**Condition:**` line naming the conformance condition some vector exercises while expecting that code. The `aee-c-N` id is the checked half of that line: `scripts/lint_failure_codes.py` resolves it through the pinned manifest and fails when no vector declares it, and when vectors do declare it but none of them expects this code. The clause after the id is this document's own summary of what the condition requires, for a reader who does not want to open the registry; the conformance suite's condition registry is the authority for its exact wording. A code with more than one emission site lists all of them — the `appendCode` helper (`evidence.ts:224`) is idempotent (it will not add a code already present), so two call sites for the same code are two independent ways of reaching the same diagnosis, not a double-count. No RFC 2119 language appears below. This is a description of what the code does, not a new set of obligations. ## GATE 0 — statement well-formedness GATE 0 (`evidence.ts:1520`, function `gate0`) is a pure function of the carried bytes: no signatures, no consumer policy. It runs first; any non-empty result here means the statement never reaches GATE 1. ### `statement-malformed` The catch-all code for a structural shape violation: the statement or a member inside it is not the JSON shape the parser expects. It has many independent emission sites, all through `appendCode(codes, "statement-malformed")`: - The whole statement fails to parse as JSON, carries a duplicate member anywhere (at any depth, not only inside an observation-record payload), exceeds the 128-level nesting bound, contains an ill-formed string scalar, or has no `predicate` member at all — `parseStatement` returns `null` in any of these cases, and `verifyStatement` synthesizes `statement-malformed` directly rather than running any gate (`evidence.ts:2544-2545`). - The `predicate` member itself is present but is not a JSON object (`evidence.ts:1044`). - `predicate.coverage` is present but is not an object (`evidence.ts:1062`). - `predicate.attackResults` is present but is not an array (`evidence.ts:1068`). - `predicate.observationRecords` is present but is not an array (`evidence.ts:1083`). - `predicate.batchRoot` is present but is not a string (`evidence.ts:1088`). - `predicate.observationEnvironment` is present but is not an object (`evidence.ts:1102`). - `observationEnvironment.observationVocabulary` is present but is not an object (`evidence.ts:1140`). - An entry of `attackResults` is present but is not an object (`evidence.ts:1156`). - A row member that must be a string (`attackId`, `containmentObserved`, `basis`, `method`, `attribution`, `actualLayer`) is present with the wrong JSON type (`evidence.ts:1168`) — this also counts the member as absent, so for `actualLayer` it additionally triggers `malformed-missing-actual-layer` at GATE 0 (the Go reference verifier's "nil-pointer double-append" parity, documented at `evidence.ts:1159-1163`). - `observationEnvironment.corpus.manifest` is present but its RFC 8785 canonicalization throws (a lone UTF-16 surrogate or an unsafe-integer number inside it) — `gate0Corpus`, `evidence.ts:1373-1375`. - Two rows in `attackResults` carry the same `attackId` — `gate0CoverageIntegrity`, `evidence.ts:1418`. - `predicate.attackResults` is entirely absent (`rowsPresent` is false) — `evidence.ts:1554`. **Condition:** `aee-c-90` — no two `attackResults` rows share an `attackId` ### `statement-type-unsupported` The statement's top-level `_type` does not equal `"https://in-toto.io/Statement/v1"` (`STATEMENT_TYPE`), checked at `evidence.ts:1524`. **Condition:** `aee-c-77` — the statement `_type` and `predicateType` URIs ### `predicate-type-unsupported` The statement's `predicateType` does not equal this verifier's implemented AEE predicate type URI (`PREDICATE_TYPE`, `https://in-toto.io/attestation/adversarial-execution-evidence/v0.7`), checked at `evidence.ts:1525`. **Condition:** `aee-c-77` — the statement `_type` and `predicateType` URIs ### `member-spelling` The predicate carries the member name `does_not_assert` — the retired snake_case spelling of `doesNotAssert` that a past spec version renamed with no accepted alias. `evidence.ts:104` names the constant (`REJECTED_SNAKE_SPELLING`); `evidence.ts:1529` fires the code whenever `has(p.raw, "does_not_assert")` is true, regardless of what the member's value is. **Condition:** `aee-c-84` — `doesNotAssert` has a single canonical spelling ### `result-vocabulary` `predicate.result` is either absent, present with the wrong JSON type (caught earlier at parse time, `evidence.ts:1050`), or present as a string that is not one of the four closed result tokens (`pass` / `pass_indirect` / `degraded` / `fail`) — checked again at GATE 0 proper via `isResultToken` (`evidence.ts:1531`). **Condition:** `aee-c-1` — the closed lowercase `result` vocabulary ### `environment-incomplete` Three distinct absences collapse to this one code: - `observationEnvironment` is absent from the predicate entirely (`evidence.ts:1535`). - `observationEnvironment` is present but is missing one of `substrate`, `corpus`, `catchPolicy`, or `networkPosture` (`evidence.ts:1538-1539`). - `observationEnvironment.corpus` is present but has no `manifest` member at all — `gate0Corpus`, `evidence.ts:1362-1363`. **Condition:** `aee-c-78` — `observationEnvironment`'s required members ### `posture-vocabulary` `observationEnvironment.networkPosture` is present, but its `posture` string is not a member of the closed `EGRESS_POSTURES` set imported from `./bindings` (`evidence.ts:1546-1547`). This check only runs when `networkPosture` itself is present — an absent `networkPosture` reports only `environment-incomplete`, never a second code for the same absence. **Condition:** `aee-c-93` — `networkPosture.posture` is a registered value ### `vocabulary-missing` `observationEnvironment.observationVocabulary` is absent — either because the whole environment is absent (`evidence.ts:1536`) or because the environment is present but carries no `observationVocabulary` sub-object (`evidence.ts:1540`). **Condition:** `aee-c-51` — `observationVocabulary` is required ### `vocabulary-not-canonical` `gate0Vocabulary` (`evidence.ts:1336-1358`) fires this for any of three independent shape violations of `observationVocabulary`: - `labels` or `caught` is missing from the vocabulary object entirely (`evidence.ts:1337-1339`). - `labels` or `caught` is present but not strictly ascending and duplicate-free under UTF-16 code-unit order (`evidence.ts:1341-1342`). - Some entry of `labels` or `caught` contains a UTF-16 surrogate code unit — the normative BMP-only string profile (`evidence.ts:1346-1347`). **Condition:** `aee-c-53` — the vocabulary arrays are sorted ascending and duplicate-free ### `vocabulary-caught-not-subset` `observationVocabulary.caught` contains an entry that does not appear in `observationVocabulary.labels` (`evidence.ts:1348-1354`). **Condition:** `aee-c-52` — `caught` is a subset of `labels` ### `vocabulary-digest-mismatch` `observationVocabulary.digest.sha256` does not equal the SHA-256 of the canonical `{"caught":[...],"labels":[...]}` reconstruction of the carried arrays (`evidence.ts:1355-1357`). **Condition:** `aee-c-54` — the vocabulary digest is JCS over `{caught, labels}` ### `corpus-digest-mismatch` `observationEnvironment.corpus.digest.sha256` does not equal the SHA-256 of the RFC 8785 canonical bytes of `corpus.manifest` (`gate0Corpus`, `evidence.ts:1366-1369`). **Condition:** `aee-c-79` — the corpus digest re-derives from the embedded manifest ### `manifest-duplicate-attack` The same attack identifier appears under more than one class in `corpus.manifest.classes` (`gate0Corpus`, `evidence.ts:1377-1384`). **Condition:** `aee-c-80` — an `attackId` falls under at most one manifest class ### `corpus-manifest-no-attacks` `corpus.manifest.classes` declares zero attack identifiers across every one of its classes — every class array is empty, or the `classes` object itself is empty (`gate0ManifestDeclaresAttacks`, `evidence.ts:1450-1456`). This is deliberately a well-formedness fault rather than a scoring outcome (see the doc comment at `evidence.ts:1425-1449` for why): with zero declared attacks, the coverage and substrate-row machinery pass vacuously and a substrate-free, no-signature statement about an arbitrary subject would otherwise be mintable. **Condition:** `aee-c-92` — the corpus manifest declares at least one attack identifier ### `manifest-expected-payloads-malformed` `corpus.manifest.expectedPayloads` is present but malformed — either its top-level shape is not a map of string arrays (`expectedPayloadsShapeOk` false), or `expectedPayloadsOk` fails because some key names an attack ID the same manifest's `classes` never declares, or some commitment array is empty, unsorted, duplicate-bearing, or not entirely lowercase 64-hex (`gate0Corpus`, `evidence.ts:1386-1389`, delegating to `expectedPayloadsOk`, `evidence.ts:1658-1665`). **Condition:** `aee-c-103` — `corpus.manifest.expectedPayloads` is well formed ### `coverage-missing` `predicate.coverage` is absent entirely (`evidence.ts:1553`). **Condition:** `aee-c-83` — the `coverage` member is required ### `coverage-incomplete` `gate0CoverageIntegrity` (`evidence.ts:1393-1423`) fires this for either of two independent violations: - The three coverage partitions — `assessedClasses`, the keys of `outOfScope`, and the keys of `routedElsewhere` — do not exhaustively and disjointly cover the manifest's declared classes: some class is missing from all three, appears in more than one, or an entry names a class the manifest does not declare (`evidence.ts:1406-1409`). - The set of `attackId`s carried across every row does not equal the set of attack identifiers implied by the classes actually listed under `assessedClasses` (`evidence.ts:1411-1412`, `1422`). **Condition:** `aee-c-82` — coverage equals the manifest exactly, at attack granularity ### `row-attack-unknown` A row's `attackId` does not appear anywhere in the corpus manifest's declared classes (`gate0CoverageIntegrity`, `evidence.ts:1420`). **Condition:** `aee-c-81` — a row's `attackId` appears in the manifest ### `malformed-missing-actual-layer` A row's `actualLayer` member is absent — this also covers the case where it was present with the wrong JSON type, which the parser already counted as absent (`evidence.ts:1566-1568`, and the parity note at `1159-1163`). **Condition:** `aee-c-47` — a missing `actualLayer` is a malformed statement rather than a fail ### `clean-row-layer-not-none` The vocabulary is otherwise well-formed, the row's `containmentObserved` is a clean (non-caught) vocabulary label, and the row's `actualLayer` is not the literal string `"none"` (`evidence.ts:1570-1571`). **Condition:** `aee-c-48` — a clean row's `actualLayer` is the literal `none` ### `subject-cardinality` The statement's `subject` array does not contain exactly one entry — checked unconditionally, regardless of row basis (`evidence.ts:1559`). **Condition:** `aee-c-58` — exactly one subject, on a statement of any basis ### `subject-sha256-missing` The statement carries at least one `basis: substrate` row, and `subject[0].digest.sha256` is not a string (`gate0SubstrateBindingInputs`, `evidence.ts:1502-1504`). This check, and every other check in `gate0SubstrateBindingInputs`, only runs when `hasSubstrateRows(p)` is true (`evidence.ts:1577`). **Condition:** `aee-c-59` — binding digest inputs are lowercase 64-hex sha256 ### `digest-not-canonical` Scoped the same way as `subject-sha256-missing` (statement carries a substrate row): a hex digest value that is present is not exactly 64 lowercase hex characters. Three call sites inside `gate0SubstrateBindingInputs`: - `observationEnvironment.runEntropy.digest.sha256` (`evidence.ts:1490`). - `subject[0].digest.sha256`, when it is present as a string but fails the lowercase-64-hex shape check (`evidence.ts:1505`). - Any of `catchPolicy.digest.sha256`, `corpus.digest.sha256`, `networkPosture.digest.sha256`, or `substrate.digest.sha256` that is present but not lowercase 64-hex (`evidence.ts:1507-1515`). **Condition:** `aee-c-59` — binding digest inputs are lowercase 64-hex sha256 ### `run-entropy-missing` The statement carries at least one `basis: substrate` row, and `observationEnvironment.runEntropy` is absent (`gate0SubstrateBindingInputs`, `evidence.ts:1489`). **Condition:** `aee-c-57` — `runEntropy` is required alongside any substrate row ### `issued-at-missing` `predicate.issuedAt` is absent (`evidence.ts:1579`). **Condition:** `aee-c-85` — `issuedAt` is required and follows the Timestamp profile ### `issued-at-malformed` Three sites converge on this code: - `issuedAt` is present but not a string, caught at parse time (`evidence.ts:1093`). - `issuedAt` is a string but does not parse under the strict RFC 3339-UTC timestamp profile `parseTimestamp` enforces — an uppercase `T`/`Z` date-time separator and zone designator only, any numeric offset accepted syntactically but only a literal zero offset passing the value check (`evidence.ts:1479-1485`, invoked at GATE 0 proper: `evidence.ts:1580`). - The same re-derivation fails again inside `gate1WithContext` when the statement carries substrate rows and the run binding must be computed from a parsed `issuedAt` (`evidence.ts:2388-2390`) — this is the identical parse re-run at a second call site, not a second condition. **Condition:** `aee-c-85` — `issuedAt` is required and follows the Timestamp profile ## GATE 1 — statement-level observation-record codes These run once over the whole `observationRecords` array, before any per-row work, inside `checkRecordsStatementLevel` (`evidence.ts:1709-1756`) and `gate1WithContext` (`evidence.ts:2357-2402`). ### `batch-root-missing` `observationRecords` is present with at least one entry, every record decodes successfully, and `predicate.batchRoot` is absent (`evidence.ts:1752`). **Condition:** `aee-c-24` — `batchRoot` is present whenever records are ### `batch-root-mismatch` `observationRecords` is present with at least one entry, every record decodes successfully, and the carried `batchRoot` does not equal the recomputed RFC 6962 Merkle root over the records' leaf hashes (`evidence.ts:1753`). **Condition:** `aee-c-25` — RFC 6962 domain-separated hashing ### `batch-root-orphaned` `observationRecords` is absent, or present but empty, while `predicate.batchRoot` is present anyway (`evidence.ts:1713-1714`). **Condition:** `aee-c-31` — `batchRoot` is omitted exactly when records are absent ### `duplicate-record` Two or more carried observation records fold to the same leaf hash — the same canonical identity (`hasDuplicateLeaf`, invoked at `evidence.ts:1750`). **Condition:** `aee-c-29` — byte-identical duplicate records are invalid ### `records-absent` The statement carries at least one `basis: substrate` row, and `observationRecords` is entirely absent (`gate1WithContext`, `evidence.ts:2382-2383`). **Condition:** `aee-c-31` — `batchRoot` is omitted exactly when records are absent ### `record-undecodable` A record's `payload` string fails strict base64 decode (`strictBase64Decode` returning `null`), checked once over the whole record set (`evidence.ts:1737-1741`) and again wherever a specific record already flagged with `decodeErr` is analyzed for per-row purposes (`analyzePayload`, `evidence.ts:1787-1789`) — the second site is the same underlying decode failure surfacing at a second read, not an independent condition. **Condition:** `aee-c-19` — a covering record's media type ends in `+json` ### `record-signatures-empty` At least one observation record's `signatures` member carries zero entries — an absent `signatures` member, an empty array, and a non-array value all collapse to the same "zero entries" fault (`anyRecordSignaturesEmpty`, `evidence.ts:1781-1783`, invoked at `evidence.ts:1732`). This is checked statement-wide, before the per-record decode loop, so its ordering against `record-undecodable` is deterministic regardless of which record in the array carries which fault (rationale at `evidence.ts:1722-1731`). **Condition:** `aee-c-91` — every observation record's `signatures` carries at least one entry ## GATE 1 — per-row coverage-validity codes These run per substrate row, inside `checkSubstrateRow` (`evidence.ts:1954-2054`), which resolves the row's `observationRefs` to specific records and evaluates each resolved record's payload (`analyzePayload`, `evidence.ts:1785-1832`) and its `aeeKind`-specific constraints (`evaluateKind`, `evidence.ts:1841-1949`). **A structural note that applies to several codes below.** Codes sourced from `analyzePayload` and `evaluateKind` — `payload-not-ijson`, `payload-not-canonical`, `payload-media-type`, `payload-missing-reserved`, `run-binding-mismatch`, `payload-commitment-malformed`, `arming-covers-nothing`, and `sealed-covers-nothing` — have a second possible emission path beyond a row resolving a bad record: `carriedRecordsCover` (`evidence.ts:2240-2258`, called from `gate1CommitmentsSubstrate` at `evidence.ts:2318`) re-runs the identical `analyzePayload`/`evaluateKind` pipeline over every carried record whose `aeeKind` is one of the four defined covering kinds (`isCoveringKind`, `evidence.ts:2196-2198`), whether or not any row's `observationRefs` resolves to it. This closes the gap where a producer carries a defective record inside a signed `batchRoot` but points every row's refs at a healthy twin instead — see the rationale at `evidence.ts:2205-2225`. `examination-covers-nothing` is also reachable this way. The three registered-non-covering codes (`record-kind-unknown-covers-nothing`, `moat-drop-covers-nothing`, `uncommitted-observation-covers-nothing`) are NOT reachable through `carriedRecordsCover`, because `isCoveringKind` excludes their kinds by construction — those three fire only through a row's own resolution path. ### `refs-empty` A substrate row's `observationRefs` member is either absent (`evidence.ts:1974`) or present as an empty array (`evidence.ts:1976`). **Condition:** `aee-c-10` — a substrate row carries a non-empty `observationRefs` ### `ref-malformed` A substrate row's `observationRefs` is present but is not an array of non-negative integers (`row.refsErr`, checked at `evidence.ts:1975`). **Condition:** `aee-c-11` — every `observationRefs` entry is an in-range integer index ### `ref-out-of-range` Two independent call sites, one condition: a row's `observationRefs` entry indexes past the end of `observationRecords`. Checked per substrate row inside `checkSubstrateRow` (`evidence.ts:1980-1983`), and again statement-wide over every row of any basis — including rows nothing else in GATE 1 reads — via `anyObservationRefOutOfRange` (`evidence.ts:2063-2069`, invoked at `evidence.ts:2369`), so an artifact-only statement with a dangling ref is still rejected even though it never enters the substrate-row pipeline. **Condition:** `aee-c-11` — every `observationRefs` entry is an in-range integer index ### `fail-closed-substrate-row` A substrate row's three closed vocabularies are jointly checked, and the row fails if any is out-of-vocabulary: `containmentObserved` is neither a caught nor a clean label, `method` is neither `intercepted` nor `reconstructed`, or `attribution` is neither `pinned` nor `paired` (`evidence.ts:1964-1972`). **Condition:** `aee-c-42` — `method` is required and closed to `intercepted`/`reconstructed` ### `payload-not-ijson` A resolved record's decoded payload bytes either fail to parse as strict I-JSON (`parseIJSON` throws, `evidence.ts:1791-1796`) or fail the raw-byte string-scalar well-formedness scan (`checkStringScalars`, `evidence.ts:1803-1806`). **Condition:** `aee-c-18` — a covering payload is valid I-JSON ### `payload-not-canonical` A resolved record's decoded payload has one of four independent problems, all inside `analyzePayload`: - The parsed I-JSON value is not an object at all (`evidence.ts:1807-1810`). - Re-emitting the parsed value in RFC 8785 canonical form does not byte-match the original payload bytes (`evidence.ts:1812-1813`). - Canonical re-emission throws — for example, a lone UTF-16 surrogate inside a string value (`evidence.ts:1814-1816`). - Some object member name anywhere in the payload contains a UTF-16 surrogate code unit, the BMP-only profile applied to member names rather than values (`evidence.ts:1817-1820`). **Condition:** `aee-c-17` — a covering payload is canonical RFC 8785 ### `payload-media-type` A resolved record's `payloadType` does not end with `+json` (`evidence.ts:1821`). **Condition:** `aee-c-19` — a covering record's media type ends in `+json` ### `payload-missing-reserved` Two distinct absences share this code: - A resolved record's payload lacks one of the three universally-reserved members — `aeeRunBinding`, `aeeKind`, or `aeeMethod` — checked in `analyzePayload` before any kind-specific work happens (`evidence.ts:1823-1828`). - An `interception`-kind record (one that does carry all three universal members) has no `aeePayloadCommitment` member at all — the initial `failCode` value `evaluateKind` assigns the `interception` case, which stands unless overridden by the commitment being present-but-malformed (`evidence.ts:1848`, `1857`, `1867`). **Condition:** `aee-c-20` — a covering payload carries the reserved `aee` members ### `run-binding-mismatch` A resolved record's `aeeRunBinding` value does not equal the run binding this verifier derives for the statement from its digest-pinned environment members (`deriveRunBinding` / `deriveStatementBinding`, `evidence.ts:1211-1265`; compared at `evidence.ts:1830`). **Condition:** `aee-c-62` — the run binding is anti-splice ### `method-cap-exceeded` A row declares `method: intercepted`, but among the records it validly resolves, the weakest signed method present is `reconstructed` — the row's claimed method is capped downward by what its covering evidence actually establishes (`evidence.ts:2048-2050`). **Condition:** `aee-c-23` — a row's method is capped by the weakest signed `aeeMethod` ### `caught-row-uncovered` A row whose `containmentObserved` is a caught label (and whose `method` is not `reconstructed`) resolves no record that validly evaluates as `aeeKind: interception` (`evidence.ts:2023`, the generic fallback when neither a specific record failure nor a covers-nothing kind explains the gap). **Condition:** `aee-c-12` — a caught intercepted row resolves an interception record ### `reconstructed-row-uncovered` A row declares `method: reconstructed` but resolves no record that validly evaluates as `aeeKind: examination` (`evidence.ts:2022`). **Condition:** `aee-c-13` — a reconstructed row resolves an examination record ### `clean-row-uncovered` A clean-label row resolves no record that validly evaluates as `aeeKind: arming`, or none that validly evaluates as `aeeKind: sealed` — both are required (`evidence.ts:2024-2027`), and the code is shared between the two requirements. **Condition:** `aee-c-14` — a clean intercepted row resolves both arming and a covering seal ### `arming-covers-nothing` A record's `aeeKind` is `arming`, but it fails one of the kind's constraints (`evidence.ts:1870-1902`): an explicit `aeeBindingVersion` this verifier does not implement; a missing `armedAt` or `aeePostureDigest`; a `method` other than `intercepted`; an `armedAt` that fails the timestamp profile or falls after `issuedAt`; a posture digest that does not equal the statement's pinned posture; malformed run-chaining syntax (`aeeRunSeq`/`aeePrevRunBinding`/`aeeChainScope`); or a missing or malformed `aeeAssessedAttacks` array. See the structural note above this section for the carried-but-unreferenced path this code also reaches through. **Condition:** `aee-c-63` — the arming record's kind constraints ### `sealed-covers-nothing` A record's `aeeKind` is `sealed`, but it fails one of the kind's constraints (`evidence.ts:1904-1929`): `aeeStillArmed` missing or `false`; a missing `aeeDropCount`; a missing `aeePostureDigest`; a `method` other than `intercepted`; a non-zero drop count with no declared bound or one that exceeds it; a posture digest that does not match the pinned posture or (when arming postures were collected) one of them; or a missing or malformed `aeeObservedSet`/`aeeObservedAttacks`. See the structural note above this section for the carried-but-unreferenced path this code also reaches through. **Condition:** `aee-c-65` — the sealed record's covering conditions ### `examination-covers-nothing` A record's `aeeKind` is `examination`, but its `method` is not `reconstructed` (`evidence.ts:1931-1933`). See the structural note above this section for the carried-but-unreferenced path this code also reaches through. **Condition:** `aee-c-66` — an examination record's signed `aeeMethod` is `reconstructed` ### `record-kind-unknown-covers-nothing` A record's `aeeKind` string is not one of the four defined covering kinds (`interception`/`arming`/`sealed`/`examination`) and not one of the two registered non-covering kinds — it falls to `evaluateKind`'s `default` arm (`evidence.ts:1945-1946`). Reachable only through a row's own resolution path, never through `carriedRecordsCover` (see the structural note above). **Condition:** `aee-c-71` — a record of unknown `aeeKind` covers nothing ### `moat-drop-covers-nothing` A record's `aeeKind` is explicitly `moat-drop` — one of the two kinds the specification registers as covering nothing whatever its payload otherwise says (`evidence.ts:1935-1940`). No member on such a record is consulted; there is no state in which it could change the outcome. Reachable only through a row's own resolution path (see the structural note above). **Condition:** `aee-c-106` — a moat-drop record covers nothing in every state ### `uncommitted-observation-covers-nothing` A record's `aeeKind` is explicitly `uncommitted-observation` — the second registered non-covering kind (`evidence.ts:1942-1943`). Reachable only through a row's own resolution path (see the structural note above). **Condition:** `aee-c-107` — an uncommitted-observation record covers nothing in every state ### `payload-commitment-malformed` An `interception`-kind record carries an `aeePayloadCommitment` member, but its array is not well-formed: not sorted ascending and duplicate-free, empty, or containing an entry that is not lowercase 64-hex (`commitmentArrayOk`, invoked at `evidence.ts:1858-1866`). This is distinct from `payload-missing-reserved`'s interception case: that fires when the member is absent; this fires when it is present and wrong. **Condition:** `aee-c-104` — an interception record's `aeePayloadCommitment` is well formed ## GATE 1 — the statement-level requirements v0.7 adds These hold over the whole statement, or over every row regardless of basis, rather than only over `basis: substrate` rows — the per-row gate above skips any row that is not substrate, so a rule belonging here would silently acquire that scope if it were checked there instead. Three run on every statement, in `gate1CommitmentsAnyBasis` (`evidence.ts:2131-2164`); three read the derived run binding and so only run when the statement has substrate rows, in `gate1CommitmentsSubstrate` (`evidence.ts:2263-2355`). ### `clean-row-contradicted` Some row's `containmentObserved` is a clean vocabulary label, yet one of the records its `observationRefs` resolves to has `aeeKind: interception` (`evidence.ts:2140-2146`). A clean row is a producer's assertion that nothing was intercepted for that attack; pointing it at an interception record contradicts that assertion regardless of the row's declared basis. **Condition:** `aee-c-94` — a clean row resolves no index to an interception record ### `interception-record-orphaned` Some carried record's `aeeKind` is `interception`, but no row whose `containmentObserved` is a caught label resolves an `observationRefs` index to it (`evidence.ts:2148-2160`). The converse of a caught row being uncovered: this is an interception record nothing claims. **Condition:** `aee-c-95` — every carried interception record is resolved by some caught row ### `sealed-record-absent` The statement carries at least one `basis: substrate` row, and no carried record both passes every byte-level payload check and validly evaluates as `aeeKind: sealed` under the derived run binding — checked unconditionally, whether or not any row resolves an index to it (`evidence.ts:2293-2312`). **Condition:** `aee-c-96` — a statement carrying a substrate row carries a satisfying sealed record ### `observed-set-mismatch` A sealed record bound to this run (`aeeRunBinding` equals the derived binding) carries an `aeeObservedSet` digest that does not equal the recomputed digest over the leaf hashes of every interception and examination record (`observedSetDigest`, compared at `evidence.ts:2279-2290`). **Condition:** `aee-c-97` — a seal's `aeeObservedSet` equals the value recomputed over the carried records ### `observed-attack-uncaught` A sealed record bound to this run names an attack identifier in `aeeObservedAttacks` for which no row exists whose `containmentObserved` is a caught label — the rule reads in one direction only: a seal *omitting* an attack obliges nothing (`evidence.ts:2320-2333`). **Condition:** `aee-c-98` — every attack a seal names has a row whose `containmentObserved` is in the caught set ### `assessed-set-exceeds-declaration` An arming record bound to this run declares an `aeeAssessedAttacks` set that is not a superset of the attack identifiers implied by the statement's own `assessedClasses` coverage — a subset check, not equality, so a run that lost coverage mid-way can still disclose the loss honestly (`evidence.ts:2336-2353`). **Condition:** `aee-c-99` — the carried `assessedClasses` are a subset of the arming record's `aeeAssessedAttacks` ## GATE 1 — the attribution binding rule Three parts of one rule, written in `attributionBindings` (`evidence.ts:2171-2188`) in the order the specification states them: existence, then the manifest's expectation, then the match. The existence part is checked first because the other two are vacuously true over an empty set otherwise — a producer that deletes the interception records would keep the stronger `pinned` label unless something asks whether any remain. ### `attribution-pinned-recordless` A row declares `attribution: pinned`, but the records its `observationRefs` resolves to include zero that evaluate to `aeeKind: interception` (`evidence.ts:2175-2176`). **Condition:** `aee-c-100` — a row declaring `attribution: pinned` resolves at least one interception record ### `attribution-unpinnable` A row declares `attribution: pinned` and resolves at least one interception record, but `corpus.manifest.expectedPayloads` carries no entry (or an empty one) for that row's `attackId` (`evidence.ts:2177-2178`). **Condition:** `aee-c-101` — a pinned row names an attack the manifest carries an `expectedPayloads` entry for ### `attribution-pin-unmatched` A row declares `attribution: pinned`, resolves at least one interception record, the manifest has a non-empty expectation for its `attackId`, but none of the resolved interception records' `aeePayloadCommitment` values intersects that expectation (`evidence.ts:2179-2184`). **Condition:** `aee-c-102` — every interception a pinned row resolves matches that attack's `expectedPayloads` ## Recompute-equality gate Runs after GATE 1 passes, comparing the carried `result` against the pure recompute. ### `result-recompute-mismatch` The carried `predicate.result` does not equal `recompute(s.predicate)` — the deterministic minimum, under `fail < degraded < pass_indirect < pass`, of three independent conditions computed purely from the rows, the on-wire vocabulary, and the coverage maps (`recompute`, `evidence.ts:2420-2447`; compared at `evidence.ts:2554-2555`). **Condition:** `aee-c-2` — `result` equals the value recomputed from the rows ## Consumer-policy step — anchor codes The four codes in this section are checked by `anchorPolicyCodes` (`evidence.ts:2508-2520`), which runs **after** GATE 2 (evidence tier) and writes its findings to `Report.policyCodes` — a field entirely separate from `Report.codes`. None of the four can ever appear in `Report.codes`, none can ever be `primaryCode`, and none ever changes `verdict`: a statement carrying one of these codes is still valid evidence, about a corpus or substrate context the consumer never pinned or pinned to the wrong value. It feeds only `Report.admitted`, the one consumer-facing admission result (spec `## Verification`). ### `corpus-anchor-mismatch` The consumer's `ConsumerPolicy.expectedCorpusDigest` is set, and it does not equal the statement's carried `observationEnvironment.corpus.digest.sha256` (`evidence.ts:2511-2513`). **Condition:** none — structurally exempt. See [Exemptions](#exemptions). ### `substrate-anchor-mismatch` The consumer's `ConsumerPolicy.expectedSubstrateDigest` is set, and it does not equal the statement's carried `observationEnvironment.substrate.digest.sha256` (`evidence.ts:2515-2517`). **Condition:** none — structurally exempt. See [Exemptions](#exemptions). ### `corpus-anchor-unpinned` The consumer supplied no `expectedCorpusDigest` at all, and did not declare `allowUnpinnedAnchors: true` (`evidence.ts:2514`). Pinning both anchors is a spec MUST, so silence is treated as a denial rather than a vacuous pass — see the rationale at `evidence.ts:2489-2501`. **Condition:** none — structurally exempt. See [Exemptions](#exemptions). ### `substrate-anchor-unpinned` The consumer supplied no `expectedSubstrateDigest` at all, and did not declare `allowUnpinnedAnchors: true` (`evidence.ts:2518`). **Condition:** none — structurally exempt. See [Exemptions](#exemptions). ## Exemptions `corpus-anchor-mismatch`, `corpus-anchor-unpinned`, `substrate-anchor-mismatch`, and `substrate-anchor-unpinned` are the only four codes in the `Code` union with zero conformance-vector coverage, and that absence is structural rather than a gap to close. Three reasons, all necessary: 1. **They are consumer-policy-dependent, not statement-dependent.** Every other code in this document is a pure function of the bytes a producer signed. These four are a function of a `ConsumerPolicy` value (`expectedCorpusDigest`, `expectedSubstrateDigest`, `allowUnpinnedAnchors`) supplied out of band by whoever calls `verifyStatement` (`evidence.ts:2542`) — the identical statement bytes produce all four possible outcomes (no policy code, `-mismatch`, `-unpinned`, or a match) depending on nothing the statement itself carries. 2. **They are recorded outside validity, on a field the conformance harness's `expected` block has no key for.** `anchorPolicyCodes` writes to `Report.policyCodes`, never `Report.codes` (`evidence.ts:2567-2572`); `verifyStatement`'s `verdict` and `codes` are already settled by the time anchor codes are computed. `aee-conformance`'s `vectors/gen_manifest.py` derives exactly five kinds of expectation from a vector — verdict, the failure-code set for a `reject` vector, the recomputed result for an `accept` vector, the declared readings for an `indeterminate` vector, and per-row evidence tiers (`gen_manifest.py:6-12`) — and none of the five is a consumer-policy admission outcome. 3. **The vector schema's `expected` block has no `admitted` key, and this is not an oversight to file a corpus request against.** A vector is a statement's bytes plus a `kind`; there is no schema slot in `MANIFEST.json` for a second input (a policy object) that a single vector would need to be paired with to make one of these four codes reproducible. Grepped confirmed empty on 2026-08-11: no `admitted` key appears in `vectors/MANIFEST.json`, any vector fixture, or `gen_manifest.py`'s output shape. Citing a forcing condition for one of these four would require inventing a policy-input schema extension the corpus does not have, which is a conformance-suite design question for the `aee-conformance` maintainers, not a documentation gap in this file. `scripts/lint_failure_codes.py` hardcodes this four-code exemption list independently of this prose section, and separately recomputes — straight from `vectors/MANIFEST.json`, never from what this document claims — the full set of codes with zero real vector coverage. If that recomputed set is ever not exactly these four (a fifth code loses its only vector, or one of these four unexpectedly gains one), the lint fails rather than silently agreeing with whichever of this prose or the corpus happens to be stale. ============================================================================== # DOCUMENT: v1/quotation-fidelity # CANONICAL: https://getprobity.dev/predicate/v1/quotation-fidelity # MARKDOWN: https://getprobity.dev/predicate/v1/quotation-fidelity.md ============================================================================== # quotation-fidelity v0.1 A signed assertion that a quoted span appears verbatim in a committed source, checkable against a 32-byte root by a reader who holds none of that source. **Predicate type:** `https://getprobity.dev/predicate/v1/quotation-fidelity` **Status:** draft. The namespace `https://in-toto.io/attestation/quotation/v0.1` is unclaimed and is the intended destination; this document is written to be portable to it. ## What it asserts That a quoted span appears verbatim at a stated position in a committed source, checkable against a 32-byte root by a reader holding none of that source, and **how the source was obtained.** ## Why this exists Three quotations were fabricated in three documents in one day, and every one was caught by a reader rather than by anything we own. The shared mistake was quoting from a *summary* of a source rather than from the source: faithful enough to feel quotable, lossy enough that the words are no longer the author's, and nothing about the sentence looks wrong on the page. A gate now catches that locally. It cannot travel. A reader who wants to check a quotation needs the capture corpus, which is 5.9 GB, gitignored and mirrored off-repository — so the check is available to us and to nobody else. This predicate is the travelling form. ## What was already built, and what was missing Two vocabularies exist and neither has teeth, which is exactly the gap: - **W3C PROV** defined `prov:Quotation` and `prov:wasQuotedFrom` in 2013 — *"some taken Entity from an earlier, larger Entity"* — with no digest, no signature and no location. - **W3C Web Annotation** defines `TextQuoteSelector` (`exact`/`prefix`/`suffix`) and `TextPositionSelector` (`start`/`end`) — the location, with no cryptographic binding. PROV supplies the relation. Web Annotation supplies the location. **The binding that would make either checkable is what nobody built.** This predicate is only that binding; the vocabulary is reused rather than reinvented. C2PA already solved the hard part and aimed it at pixels: `c2pa.hash.bmff` does Merkle-over-chunks with an init hash and a hash array, so a verifier can check one chunk without holding the file. For text its specification mandates a whole-asset hash and states that partial verification is *"not supported by design."* The machinery transfers to canonical text segments at the cost of a translation, not a new cryptographic idea. ## Shape ### 1. The source is committed as a Merkle tree over canonical text segments Not as a digest of the whole file. A whole-file digest forces a verifier to hold the whole file, which is the thing that makes the local gate untravellable. The tree is **RFC 9162** (the Certificate Transparency tree), chosen because it is the only candidate that is short-proof, short-state **and already registered**: RFC 9942 registers it as `RFC9162_SHA256` with a COSE-carryable inclusion proof, so the commitment is natively expressible in SCITT (RFC 9943) without inventing a structure. At ~1 KB segments over a 5.9 GB corpus (n ≈ 6×10⁶) an inclusion proof is ~23 sibling hashes ≈ **736 bytes**, verifier state is **32 bytes**, and the check is ~23 SHA-256 compressions. Alternatives were considered and lose structurally, not incidentally. Redactable and sanitisable signatures prove membership but not **position**, and have no COSE algorithm identifier. KZG and vector commitments give 48-byte proofs but need a trusted setup and pairings to save 700 bytes nobody needs. SD-JWT is flat, so a verifier would hold six million digests. BBS+ buys unlinkability, which a public citation corpus actively does not want. ### 2. Canonical segmentation is normative, and it is the more durable claim Two implementations that segment differently produce different roots for the same bytes, so the segmentation profile is part of the predicate rather than an implementation detail. **Whoever writes this profile defines what "verbatim" means downstream** — a more durable contribution than the predicate itself. The normalisation is the one this project's gate already learned the hard way, and each rule cost a real defect: NFKC; typographic quotes, dashes and non-breaking spaces folded; soft hyphens removed; a hyphen before a line break preserved (removing it broke a verbatim quotation of a compound word that happened to wrap); `\n` and `\t` **inside JSON string captures** unescaped (a document quoted inside a JSON capture otherwise fails at 96%); leading blockquote and list markers stripped per line, because specifications put their normative callouts in blockquotes and that is precisely the text people quote; whitespace collapsed; lowercased. An offset map back to raw bytes is **required**, not optional. Without it an implementation can locate a quotation and still not say where it lives in the artifact being attested to. ### 3. Acquisition is required, and a predicate that cannot state it is not signable This is the field that keeps the artifact honest, and it exists because the first design of this predicate was circular. Committing an excerpt beside a digest and having a verifier recompute one from the other proves only that **the author was self-consistent** — no step contacts the source. Signing that is worse than leaving it unsigned, because a signature over a self-reported capture converts an unverified assertion into an attested one. For a project whose thesis is evidence a distrusting party can re-check, that is the failure we exist to prevent, in the artifact we sell. Requiring the strongest tier instead would make the predicate useless for the sources that matter most. A competitor's page has no third-party custody and will attest nothing; a chat thread has neither. Refusing to emit evidence about those is refusing to do the work. Emit it and say what it is — the same rule this predicate applies to `UNCHECKED`, and that the sibling execution predicate applies to a self-reported clean row. *(The internal design note behind this field is `docs/architecture/ACQUISITION-PROVENANCE.md` in the Probity repository. It is named rather than linked: this document is published standalone, and a relative link out of the specification tree resolves to nothing for a reader who has only the specification — which is the exact defect that once left 139 cross-references broken on the published site.)* | `acquisition` | Means | A challenger can | |---|---|---| | `self-captured` | We fetched it and stored it | Re-fetch and compare, if the source still exists unchanged | | `self-captured-witnessed` | Plus a digest in an append-only log at capture time | Prove we did not alter the capture afterwards, and place it in time | | `third-party-custody` | An independent archive holds a copy | Fetch the bytes from someone who is not us | | `origin-attested` | Cryptographic proof the origin served these bytes | Verify without trusting us or the archive | **The weakest value in a bundle is surfaced in every rendering of it.** A bundle is exactly as strong as its weakest acquisition, and a reader who has to compute that will not. `self-captured` is never upgraded by re-signing; only by obtaining different evidence. ## Verdicts Three, and the middle one is the point: | | | |---|---| | `VERIFIED` | The span appears verbatim in the committed source at the stated position | | `ALTERED` | The source is committed and the wording differs. **A finding.** | | `UNCHECKED` | No commitment covers this span. **Not a finding, and must never be reported as one.** | An unchecked quotation is exactly as unverified as a fabricated one from this predicate's point of view, and saying so is the honest output. A verifier that reports `UNCHECKED` as a pass has reproduced the defect this exists to refuse. An **elision** is not an alteration. A span written with an ellipsis asserts that its parts appear in that order with text omitted between them, which is honest quoting; each segment is verified separately and in order, and segments too short to be distinctive are rejected so an elision cannot smuggle in a match on stopwords. ## Carriage DSSE, over `application/vnd.in-toto+json`. **Not a Rekor `intoto` entry.** Rekor v2 GA dropped that entry type; only `hashedrekord` and `dsse` remain, so any design assuming an `intoto` entry is already broken against current infrastructure. ## What this does not assert - That the source is **true**, or that its author meant what the quotation implies. It asserts transcription fidelity and nothing about meaning. - That the excerpt is **authentic** at `acquisition: self-captured`. At that tier anyone with commit rights can commit a false excerpt and a matching root. That is why the field is required and why its weakest value is surfaced — the limit is made legible rather than hidden. - That the source still says this **today**. The commitment is to bytes captured at a stated time. Sources drift: better than 75% of scholarly references were measured to have drifted in one study, so a pin against a live source is a review scheduler, not a security control. ============================================================================== Contact: contact@getprobity.dev Index: https://getprobity.dev/llms.txt Specs: https://getprobity.dev/predicate Verify: https://getprobity.dev/verify/README.txt