# 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": "<kernel-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": "<base64(canonical Statement bytes)>",
  "payloadType": "application/vnd.in-toto+json",
  "signatures": [{"keyid": "<64 lowercase hex>", "sig": "<base64(raw 64-byte ed25519 signature over the PAE)>"}]
}
```

- **`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-<digest-hex>` 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 <sig-file> <pae-file>
```

where `<pae-file>` holds the PAE bytes reconstructed per the construction above and `<sig-file>`
holds the base64 `sig` from the envelope. The producer signs the same `<pae-file>` with
`cosign sign-blob --yes --key env://<secret> --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.
