Record signing and tier S

Normative for RUNBOOK.md §7 and §8. Implementation: packages/attest (record-signature.ts).

content_hash proves a record has not changed since the catalog fetched it. It does not say who published it, and it cannot: an attacker who can get a record into the catalog can get a consistent hash for it as well. A signature is the other half, and tier S is the publisher tier that rests on one.

What is signed

Not the document. A short statement about it:

{
  "signed_schema": "runbook-record-signature/v1",
  "ref": "acme/kafka-lag",
  "semver": "1.2.0",
  "content_hash": "sha256:…",
  "signed_at": "2026-09-03"
}

The claim being made is this publisher stands behind this ref at this hash. Signing the whole document would tie the signature to the catalog's serving format — every field the build adds would be a question about whether the publisher's claim survived it, which is a question about our pipeline rather than about their claim.

signed_at is a date. An hour would make a series of signatures a movement log of one organization's working day.

The bytes a signature covers

The same canonicalization as attestations, and there is exactly one in this product: canonical JSON of the statement — object keys sorted by code point, no whitespace, UTF-8, no floats. Nothing else is hashed, salted or prefixed.

That is what jq -cS emits, which is the point: a publisher must be able to sign without our code.

jq -j -cS . statement.json > statement.canonical.json
openssl pkeyutl -sign -rawin -inkey ed25519.pem \
  -in statement.canonical.json | base64 -w0

-j is not a detail. Without it jq ends the file with a newline, openssl signs the file's bytes, and the signature covers one byte the canonicalization does not define — 202 bytes instead of 201 for the statement below. Every verifier will reject it, and the message it gives is that the record changed after signing, which sends a publisher looking in the wrong place.

Check your pipeline before you publish

The bytes for one statement, so an implementation can be compared against something rather than against a description of something:

{
  "signed_schema": "runbook-record-signature/v1",
  "ref": "acme/kafka-lag",
  "semver": "1.2.0",
  "content_hash": "sha256:0000000000000000000000000000000000000000000000000000000000000000",
  "signed_at": "2026-09-03"
}

canonicalizes to exactly these 201 bytes, with no trailing newline:

{"content_hash":"sha256:0000000000000000000000000000000000000000000000000000000000000000","ref":"acme/kafka-lag","semver":"1.2.0","signed_at":"2026-09-03","signed_schema":"runbook-record-signature/v1"}

If your pipeline produces those bytes, it will produce the right bytes for any statement: what remains is Ed25519, which your library already gets right.

The result goes beside the statement:

{ "…the statement…": "", "key_id": "acme-2026", "sig": "base64…" }

Where the key lives

https://<publisher-origin>/.well-known/runbooks-attestation-keys.json — the same discovery path attestations use, for the same reason: the catalog is not a key authority. A publisher who can serve a file can be verified, and nobody asks us for permission to be checkable.

A key is bound to a namespace by the namespace's own verification (see publishers.md). Tier V proves who holds the name; a signature proves who holds the key; tier S needs both, because either alone is a different claim.

Rotation

Rotation adds a key id and keeps the old one listed with retired_on. A signature made before that date still verifies forever: an attestation or a record signed last year must stay checkable, so a retired key stops signing and never stops verifying.

Revocation, decided now rather than during an incident

A revoked key carries a reason, and the reason decides what happens to what it signed. There are exactly two:

Reason What it means What its signatures are worth
retired The publisher rotated. Nobody else held the key. Signatures dated before revoked_on stand. Later ones are refused.
compromised Somebody else may hold it. Every signature it made is refused, including ones dated earlier.

The asymmetry is deliberate and is the whole reason this is written down in advance. Treating a rotation as a compromise would silently unsign a publisher's back catalogue every time they changed keys. Treating a compromise as a rotation would trust signed_at — a field the holder of the key writes — which is trusting the attacker to date their own forgery honestly.

A publisher whose only key is revoked drops to tier V. The records that key signed do not change status: what it signed while it was live is still what it signed.

Tier S

Three conditions, all computed at read time and none stored:

  1. the publisher is already verified (tier V), on its own evidence and within the reverification window;
  2. it publishes at least one key that is currently signing — not retired, not revoked;
  3. the promotion was approved by two different people (§15).

The two-person rule is not ceremony. Tier S is the tier whose signature a reader is asked to rely on, and a promotion one person can grant is a promotion one compromised account can grant.

Losing any of the three drops the tier, at read time, without anybody running a job — for the same reason trust decays and verification lapses: a stored tier outlives its proof.

A signature and upstream_state are independent

A signed record whose upstream has since changed is still authentically what that publisher published. The two answer different questions — did they publish this and does the source still say it — and the statement carries no upstream field at all, so there is no path by which an edit upstream could unsign somebody's work.

What a reader is told

Status Meaning
valid The signature matches, by a key that was entitled to make it.
invalid The signature does not match these bytes: the record changed after signing, or it is not this publisher's.
unknown-key The key is not in the publisher's published set. A fact about key distribution, not evidence of forgery.
revoked It verifies, and is refused anyway, by one of the rules above.

unknown-key and invalid are never collapsed. One says the signature is wrong; the other says we have not been told whose it is, and merging them lets a key-distribution problem look like an attack — or an attack look like a key nobody has fetched yet.