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:
- the publisher is already verified (tier V), on its own evidence and within the reverification window;
- it publishes at least one key that is currently signing — not retired, not revoked;
- 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.