Attestations

Normative for RUNBOOK.md §8. Schema: schema/attestation.json. Implementation: packages/attest.

T3 exists so that trust can rise above what the catalog can check statically without the catalog owning a runner. That only works if somebody who is not us can issue a claim we can verify — which makes this format the load-bearing part, and the reason it is published before anything consumes it. Published late, a format ossifies in our own code first, and the first external verifier arrives to find a de-facto standard nobody agreed to.

What a statement says

{
  "attestation_schema": "runbook-attestation/v1",
  "predicate_type": "https://runbooks.directory/attestation/run/v1",
  "subject": {
    "runbook": "std/k8s-node-not-ready-drain",
    "semver": "1.0.0",
    "content_hash": "sha256:…"
  },
  "predicate": {
    "issuer": "https://verifier.example.com",
    "env_class": "staging",
    "outcome": "success",
    "runtime_profile": "R2",
    "issued_at": "2026-09-21"
  },
  "signatures": [{ "key_id": "verifier-2026-09", "sig": "…" }]
}

The subject binds to content_hash, not to a name. A rename cannot move an attestation and an edit cannot inherit one. That is the whole reason the hash exists.

The signable bytes

The acceptance test for this section is that two independent implementations signing the same statement produce byte-identical input. That is a property of a written rule, so here is the rule:

  1. Remove the signatures field. A signature cannot cover itself.
  2. Serialize the remainder as JSON with object keys sorted by code point, ascending, at every depth. Code point, not code unit: the two orders differ once a key above U+FFFF meets one in U+E000..U+FFFF, and code point is what jq -cS, Python's sort_keys and Go's encoding/json produce — the tools a publisher signs with.
  3. No whitespace.
  4. Arrays keep their order. Order is meaning.
  5. A key whose value is absent or undefined is omitted; there is no null placeholder.
  6. Strings are JSON strings with the shortest escapes JSON permits.
  7. Numbers must be safe integers. A float is refused rather than serialized: two implementations will disagree about 0.1 before they disagree about anything else here.
  8. Encode as UTF-8. Those bytes are what Ed25519 signs.

This is not the same canonicalization the catalog uses for content_hash. That one drops catalog-assigned keys — trust, id, the upstream markers — because they are not content. Using it here would silently drop a key named id and produce a signature over a statement nobody sent.

The suite

Ed25519, and nothing else. One suite means no negotiation, and no negotiation means no downgrade: a format that accepts two suites eventually accepts the weaker one because something in the chain only implemented that.

Keys: discovery and rotation

A key set lives on the issuer's own origin:

https://<issuer-origin>/.well-known/runbooks-attestation-keys.json
{ "keys": [ { "key_id": "verifier-2026-09", "key": { "crv": "Ed25519", "kty": "OKP", "x": "…" } } ] }

The catalog is deliberately not a key authority. An issuer who can serve a file can be verified, and nobody needs our permission to be checkable — which is the same argument as publishing the schema rather than shipping a validator.

Rotation adds a key id and keeps the old one listed. An attestation signed last year has to stay verifiable, so a rotated key is retired for signing (retired_on) and never removed for verifying. A key set that drops keys invalidates history it has no right to invalidate.

An unknown key is not a bad signature. They are different facts — one says the signature is wrong, the other says we have not been told whose it is — and a verifier that conflates them lets a key-distribution problem look like an attack.

Self-attestation

A vendor attesting its own runbooks is valid and honest, as long as it is visible. Visible means machine-checkable: selfAttested() compares the issuer against the subject's publisher namespace so a surface can label it, rather than leaving a reader to notice that two strings match.

Expiry

An attestation does not expire. It is a claim about a run on a date, and that stays true forever.

§8's 180-day decay is a catalog policy applied on read: we stop counting an old attestation toward T3 or T4. The statement is still valid, still verifiable, and would still count for anybody with a different policy. Conflating the two would mean telling a publisher their signature became invalid, which is not something we are in a position to say.

CloudEvents

The statement is a valid CloudEvents data payload without modification, with type: directory.runbooks.attestation.v1 — the same binding the run report uses (§14), so a producer already emitting one adds a mapping rather than a pipeline.

Signing one from a supervised run

An R2 supervisor in an operator's own environment is the party that can say this honestly, and runbooks hook --sign-with <jwk> --key-id <id> --issuer <uri> is how it does. Signing is opt-in per run: a run opened without an attestTo path produces nothing, because a supervisor that signed by default would be signing on behalf of somebody who did not decide to.

What the statement says is what the run recorded — the outcome comes from the same function the run report uses, so a report and an attestation about one run cannot disagree — and it says nothing else. There is no field for a note. The statement is validated against this schema before it is signed: a signature applied first and checked afterwards is a signature over whatever was there.

By signing, an operator claims:

Who may issue one

Anyone who publishes a key and signs. There is no status to apply for and no list to be admitted to: an attestation counts because a signature checks out against a key its issuer published, and that is checkable by anybody holding the file (docs/decisions/Q1-independent-verifiers.md).

/v1/verifiers.json records who this catalog has seen issuing attestations, where their keys are published, and when they were first seen. It describes; it does not permit. An issuer absent from it is not disqualified — a first attestation from somebody new counts — and an issuer present in it gains nothing by being there.

What the file does carry is removal, and removal has two reasons that do different things:

Reason What it means Their attestations
withdrawn They stopped verifying. Nothing is alleged. Stand until they expire on their own.
discredited Their attestations cannot be relied on. Stop counting immediately, whatever their date.

Withdrawing must not retroactively unmake honest work; discrediting must not leave signed claims standing for six months because the calendar says they are young.

Self-attestation is allowed and always visible. A vendor attesting its own runbooks is honest, and §8 permits it on condition that attester and publisher are seen to coincide. Whether they do is computed from the two identities — one definition, shared by the verifier and by every badge — and shown on every surface that shows a level.