# Attestations

Normative for RUNBOOK.md §8. Schema: [`schema/attestation.json`](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

```json
{
  "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
```

```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 — `prod` means 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.
