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:
- Remove the
signaturesfield. A signature cannot cover itself. - 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'ssort_keysand Go'sencoding/jsonproduce — the tools a publisher signs with. - No whitespace.
- Arrays keep their order. Order is meaning.
- A key whose value is absent or
undefinedis omitted; there is nonullplaceholder. - Strings are JSON strings with the shortest escapes JSON permits.
- Numbers must be safe integers. A float is refused rather than serialized: two
implementations will disagree about
0.1before they disagree about anything else here. - 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:
- they ran that exact version, by content hash rather than version number, under the supervision profile named;
- the outcome is what their supervisor recorded, not what they concluded afterwards;
- the environment class is honest —
prodmeans production, and this is the one lie the format cannot detect; - they are identifiable as the issuer, by a key they publish. This is not a run report and it is not anonymous;
- they cannot unsay it quietly: an attestation from a withdrawn issuer is refused by date, and one from a discredited issuer is refused entirely.
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.