ADR-0010 — Rotation destroys the old private key, and the writer enforces continuity¶
Status: accepted · week 7 (2026-10-09)
Implements: SPEC §10.5 (qed keys rotate), §10.7 (verification)
Relates to: ADR-0005 (record encoding), ADR-0009 (the outbox and one leader)
Context¶
SPEC §10.5 fixes the shape of rotation: a new key becomes active, and a
key_rotated{old_key_id, new_key_id, new_public_pem} record is appended,
signed by the old key, for continuity. qed-verify already enforced that
rule; nothing produced the record.
Three questions the spec leaves open, and each has a wrong answer that is quieter than the right one.
Decision 1 — The old private key is destroyed¶
Rotation exists because a key might have leaked. A rotation that leaves the old
key on disk — in retired/, or behind a --keep-old flag — has reduced
nothing, and the flag would be used, because losing a key feels worse than
keeping one.
The threat is not editing an existing record. hash covers payload_hash, and
the next record's prev_hash covers hash, so an edit breaks linkage and
qed-verify says so. The threat is fabrication: with the retired private
key an attacker can produce a whole alternative history — genesis onwards,
internally consistent, correctly signed — that verifies under a key an auditor
still trusts for the period before the rotation. The only defence is that the
key no longer exists.
So finish_rotation overwrites evidence-key.pem with os.replace, which is
atomic on POSIX and Windows. There is no copy and no flag.
The public key is kept, and must be: without it the chain before the
rotation cannot be verified at all. This is why qed keys export-public now
prints every key rather than the active one — the previous behaviour made
"send the auditor your public key" produce a file that verifies half the chain
and reports unknown_key for the rest.
Decision 2 — The record is appended before the key is activated¶
Three steps, in this order:
- generate the new key and write it to
evidence-key.pem.incoming; - append
key_rotated, signed by the retiring key; os.replacethe pending key over the active one.
(3) before (2) would leave the chain on the old key with the next record signed by the new one and nothing in between saying so — a discontinuity indistinguishable from a forged chain, produced by the correct command.
(1) after (2) would mean a crash between them left a chain whose last record announces a key that was never written down. Unrecoverable: the chain cannot continue (nothing can sign as the announced key) and cannot be rewound (the record is immutable).
Staged first, the two interruption points are both recoverable and both
visible. pending_rotation() reports a leftover .incoming file, and the
writer's own check (below) reports a chain whose expected key is not active.
Decision 3 — The writer enforces continuity, not only the verifier¶
ChainWriter.__init__ now reads the chain's last record and refuses
(KeyDiscontinuity) unless the active key is either the one that signed it, or
the new_key_id of a key_rotated record sitting at the tip.
The verifier had this rule already, which is the right place for an auditor and the wrong moment for an operator: by then the records exist, are immutable, and are unverifiable. The causes are ordinary — a key file restored from a backup taken before a rotation, two deployments pointed at one database with different secrets, a rotation interrupted between steps 2 and 3 — and in every case the instance would go on appending records that look forged.
One read of one record at startup turns a permanent, silent defect into a refusal with a sentence.
Consequences¶
- Rotation is direct, never deferred.
ChainWriter.append_directexists for the two record types whose meaning depends on their position: a checkpoint covers a range ending just before it, and akey_rotatedintroduces the key that signs everything after it. Queued behind other records, either would describe a position it no longer occupied — and akey_rotatedchained after records the new key had already signed is exactly whatqed-verifyreads as a discontinuity. - On the HA path, rotation takes the chain lock or refuses. A running
qed serveholds that lock for its lifetime, soqed keys rotatethere reportsRotationRefusedand says to stop the server or rotate through the instance, rather than racing the leader. SPEC §13'sPOST /admin/keys:rotateis where that goes (week 10 adds the approval it needs — SPEC §17: qed governs its own admin operations). - Anything queued is drained before the record is appended. Those records were signed by the old key, and chaining them after the record that retired it would invert the one ordering rotation depends on.
- The CLI prints these as sentences.
QedGroup.invokecatchesInstanceMismatchandKeyDiscontinuityfor every command. On the group rather than inmain(), becausemainis not the only way in — the test runner and anything embeddingqed.cli.appinvoke the group directly.
Alternatives rejected¶
Keep the old private key, encrypted. Still a key, still a fabrication route, and now with a passphrase somebody has to store next to it.
Re-sign history under the new key. Would make every prior hash wrong and
every prev_hash a lie. The chain is append-only; that is the entire claim.
Let the verifier be the only place continuity is checked. It is the authoritative place and it is far too late. Both, then.