Skip to content
New kind of attack — caught and fixed before it was publicly known·Read the timeline
Probity Open Predicate Standard
/predicate/v1/adversarial-execution-evidence
View raw .md →

Adversarial Execution Evidence, v0.7

<!-- restatement-banner:begin Every figure inside this block is recomputed by scripts/lint_spec_restatement.py and is a build failure when it is wrong -- the block asserts facts about two files, and the first version of it stated the line count of the file it had not yet joined. Do not edit a number here by hand; re-derive with --repin and read what it reports. These sentinels are the gate's parse boundary and the reason the banner's own MUSTs are not counted as obligations. Removing or moving one is a build failure. -->

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. 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.

<!-- restatement-banner:end -->

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, 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 <server> 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.

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.

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).

"subject": [
  { "name": "<scanned-sut-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

{
  "aeeBindingVersion": "2",
  "catchPolicy": "<catchPolicy.digest.sha256>",
  "corpus": "<corpus.digest.sha256>",
  "networkPosture": "<sha256 of the RFC 8785 canonicalization of the carried networkPosture object>",
  "observationVocabulary": "<observationVocabulary.digest.sha256>",
  "runEntropy": "<runEntropy.digest.sha256>",
  "subject": "<subject[0].digest.sha256>",
  "substrate": "<substrate.digest.sha256>"
}

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

{
  "result": "fail",
  "observationEnvironment": {
    "substrate": {
      "name": "<attestation-A-subject-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": "<base64std(canonical +json bytes the substrate signed)>",
      "payloadType": "application/vnd.probity.aee-observation.v1+json",
      "signatures": [
        { "keyid": "<hex-key-id>", "sig": "<base64-ed25519-per-record-signature>" }
      ]
    }
  ],
  "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 <placeholders>.

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. 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 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 (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 attackIds 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 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; 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 attackIds, 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), 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 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.

  • 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": <hex>, "sig": <base64-ed25519> }. 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 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 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).

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, 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 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 - 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) - 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 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 - 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 - the envelope, used at TWO layers: the outer statement envelope (see 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 - 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.
Get access

Run your agents through Probity.

A few details about your setup and we'll get you gating agents fast.