Changing Your Key Without Invalidating Your Past
Rotation writes a boundary marker onto the record itself โ the exact point in your history where the old key stopped being allowed โ so entries signed before it stay valid and entries signed after it do not.
How replacing a signing key is supposed to work
Your data here lives in chains โ append-only sequences of small entries, each one signed, each one naming the fingerprint of the entry before it. A fingerprint is just a hash of that entry's contents, so naming the previous one makes the sequence checkable: you cannot quietly change something in the middle, because every entry after it names a fingerprint that no longer matches. Each thing you care about keeping โ a friendship, a shared album, your own identity โ gets its own chain, and each chain has branches, the way a document has drafts, so different kinds of entry can advance independently. Every chain has its own signing key rather than one master key signing everything you own. (We wrote about the chain itself in one idea under everything.)
Chains are checked by peers โ other people's copies of the system, and your own other devices, each verifying your entries independently rather than trusting a server to have done it.
Sooner or later the signing key has to change. You add a second device and the right to write is now shared between two machines. You retire a laptop. A key is suspected. Replacing a key is called rotation, and any system that signs a long-lived history has to do it eventually โ a key you can never replace is a key you can never recover from.
Rotation is easy to describe and hard to get right, because a chain is a history. The moment you retire a key, every entry ever signed with it becomes ambiguous. It could be something you legitimately wrote before the change, which must stay valid forever, or something written after it by someone who still holds the retired key, which must be rejected. Otherwise rotating a key either erases your past or accomplishes nothing.
The signature cannot tell those apart. Both verify perfectly against the same old public key. The cryptography is working exactly as designed and is, on its own, useless for this question. What tells them apart is a number: each branch counts its rotations, and every entry records which of those numbered stretches โ which era โ it was signed under.
flowchart TD A["Entry arrives, signed
under era N"] --> B{"Compare N to this
branch's current era"} B -->|"N is current"| OK1["Verify against the
keys registered for era N"] B -->|"N is newer"| X1["Refuse โ no such
era has begun"] B -->|"N is older"| C{"Is there a rotation record
leaving era N?"} C -->|No| X2["Refuse โ nothing
legitimises the old key"] C -->|Yes| D["Walk back from the record's
boundary fingerprint"] D --> E{"Is this entry
in the walk?"} E -->|Yes| OK2["Accept โ written
before the rotation"] E -->|No| X3["Refuse โ written after,
with a retired key"]
The boundary is written onto the record
What separates the two cases is position in the chain. So the rotation is not a note in a config file; it is written onto the chain as an entry of its own, called an anchor because it pins one moment down. The anchor names the era being left, the era being entered, the set of keys valid in the new era โ a set, because the right to sign can be shared across several devices, which is its own piece of cryptography โ and the fingerprint of the entry that was the tip of that branch at the instant of the change. The field's word for one of those numbered stretches is an epoch.
The era label is not merely descriptive: it is the lookup key for which public key the signature is checked against, and every era's key is minted fresh when the rotation happens rather than carried over. Relabelling an entry to another era therefore selects a key it was never signed with, and the check fails. But notice the limit of what a passing check buys you: a valid signature proves who, never when. Nothing about the signature itself says which side of the boundary the entry was written on. That is the walk's job, and it is why the walk exists.
For an entry whose era is older than the branch's current one, the check is that walk: find the anchor leaving that era, take the boundary fingerprint it recorded, and follow parent links backwards. If the entry turns up, it existed before the boundary, and it is accepted under the old era's keys โ which are kept on file rather than deleted, precisely so that your past stays readable. If the walk completes without finding it, the entry was made with a retired key after the boundary. Refuse. The walk is bounded โ a hundred thousand steps and a guard against loops โ so a hostile entry cannot turn a verification into an endless traversal. And if the walk runs into a gap, an ancestor this peer simply does not hold, the question is undecidable rather than answered no, and the entry is refused โ but with a reason that keeps it for another look rather than dropping it.
Two properties do the load-bearing work here.
An anchor cannot make new keys trusted by itself. The anchor is necessarily signed under the old authority, since the new authority does not exist until this entry lands โ which would otherwise be an alarming amount of power for a key you are in the middle of retiring. So a key appearing for the first time in an anchor is not installed on the strength of the anchor. Each newly-named key requires separate on-chain evidence that the device holding it was actually paired; absent that, the key is not trusted but held pending the evidence. The evidence is another entry on the same chain under the same authority, so it is corroboration and an audit trail โ a second thing an attacker must forge, and a permanent record that they did โ not an independent second factor.
Anchors are immutable. The first anchor recorded for a given branch and the era it enters wins; a later one for that era is ignored rather than overwriting it, because the whole procedure depends on the boundary not moving underneath it. That rule is local, though. It guarantees the boundary never shifts on a given peer; it is not a claim that two peers shown conflicting anchors end up agreeing, and the discard is quiet rather than raised as an alarm.
The default, and what happens when things are missing
The default configuration is that none of the older-era machinery runs. A chain starts with a single writer: one device, era zero, no rotation has ever happened, so every arriving entry takes the ordinary path. For someone who never adds a second device, this describes a mechanism they never touch. The first rotation happens when a chain is promoted from one writer to several โ which is what adding a device does, and which is not the trivial operation it sounds like. An era-zero record is also written when a chain is first set up โ for a two-person chain it is the proof that its signer was installed at all, and the chain refuses to accept ordinary entries until it exists. Those records point at themselves, since there is nothing behind them to point at, and carry an all-zero placeholder where a boundary fingerprint would go. They mark no real boundary, so the walk filters them out before looking for one.
The fallback, when something is missing, is to fail closed. Every branch of that decision tree that cannot reach an affirmative answer refuses: no anchor for the old era, no key record for the era and branch claimed, an era that has not begun. "I cannot tell" is never treated as "probably fine".
There is one designed exception, and it is worth describing precisely because of what it does not yet do. A peer handed a chain's current state rather than its history has nothing to walk, so the verifier carries a recognition rule for a witnessed provenance class: a key record installed with a pointer to a verified vouching statement โ from someone who belongs to a group both parties are in, who is not the key's owner, and whose signature over that statement was checked when the record was installed โ is cleared without the walk, and the signature check still runs afterwards. A record missing any part of that witness is not recognised and falls through to refusal. But the ceremony that produces such a record has no emitter in the shipped code; only the receiving half exists. So the recognition rule is real and the records it would recognise are not yet produced, which means that particular exception cannot fire today.
One other path does accept without a walk, and it is worth naming rather than leaving a reader to find it: the boundary record itself. By construction it is signed under the old key and cannot be its own ancestor, so walking for it would be incoherent โ and it is recognised only when the chain's own stored anchor already names that very entry as its source. The chain has to have written down which entry marks its boundary before that entry is allowed to skip the walk, which is a narrower thing than an escape hatch.
Refusing is not always terminal, either. Four of the refusal reasons hold the entry for re-judgement rather than dropping it โ two already did so when these repairs landed, and two more, covering a gap in locally held history and a missing anchor, were added in July 2026. When the missing state arrives the entry is judged again, and a genuine forgery is refused just as hard on the second look. What holds is bounded, per chain and overall, and evicted when it fails verification on the retry, so a hostile peer cannot grow it without limit.
That grading matters, because fail-closed has a real cost: a refused entry strands everything that named it as a parent. The honest limit of the design is on the other side, though. A peer that has not yet received an anchor still believes the old era is current, so on that peer a retired key keeps working on the ordinary path โ no refusal, no reason code. Nothing here bounds how long that lasts. Revocation is as fast as delivery, and delivery is not guaranteed.
What was wrong
The boundary fingerprint โ the single field the entire older-era decision rests on โ was not being written.
It failed in two places at once, which is why it survived. On the writing side, the promotion from one writer to several treated the boundary fingerprint as an optional argument and did not pass it; both call sites omitted it. On the reading side, the step that turns the recorded anchor into the state the decision consults found the field absent and filled it in with the all-zero placeholder โ the value that is legitimate on a back-filled record and meaningless anywhere else. It was not nonsense but a legal value borrowed from a neighbouring case, which is exactly the kind of value a reviewer's eye slides over.
The result was an anchor that looked entirely healthy. Right shape, right place, and a boundary field that passed its format check. It just pointed at an entry that has never existed in any chain. Walking backwards from an entry that does not exist finds nothing, and at the time a gap in the walk was not yet distinguished from a walk that had completed โ so every genuine pre-rotation entry failed to appear and was judged the forgery case: signed with a retired key after the boundary. Legitimate history, produced by the rightful owner, refused by their own peers after they added a device โ and everything downstream of each refused entry stranded behind it.
Who it affected: it surfaced in our own multi-peer test runs and was diagnosed from those logs rather than reported by anyone relying on it. Two details keep that from being luck. The contract already said callers of this kind must supply a real boundary hash โ written down, simply not enforced. And the sibling handler for the closely-related rotation event already skipped and warned rather than inventing a value. The correct behaviour was one file over.
How it was fixed
Two changes, deliberately separate, because they fail differently.
The reader stopped inventing the value. An anchor that is not a back-filled record and does not carry a real boundary fingerprint is now dropped with a loud warning naming the consequence it prevents, rather than completed with a placeholder. Demonstrated the way a guard should be: with the guard switched off, the test asserting the refusal fails and the anchor is stored with its zero fingerprint; with it on, all three pass, including the one confirming a back-filled record still gets its placeholder.
That also repairs the reason code, which is the part worth noticing. A malformed anchor used to surface as an accusation of forgery; it now surfaces as the honest "no rotation record here" โ the same refusal, no longer blaming the wrong party. Distinct reason codes are only worth having if the wrong one cannot be manufactured by a missing field.
The writer started supplying it. Rather than patching the two callers that had forgotten the argument, the value is resolved at the single point where the record is emitted, from the tip of the branch being promoted โ the boundary the anchor exists to mark. That covers both existing callers and any future one, which is the difference between fixing an instance and closing a class. The resolution has a middle path worth knowing about: on a branch with no tip yet it substitutes the chain's metadata branch head, quietly, and only when neither exists does the promotion fail loudly rather than write an anchor that means nothing. That substitution is worth assessing, not merely disclosing: it fires only when the branch being promoted has no head in the promoting device's own store โ so, locally at least, no pre-rotation history for a boundary to adjudicate โ and unlike the placeholder it names a real entry in the same chain, so a walk starting from it terminates honestly instead of pointing nowhere. That is the narrow and benign form of the same move this article is about. It is still a substitution rather than a measurement, and it is still quiet.
The ordering between those two changes is a trap worth pointing at, and the code comment records it. The reader-side refusal alone would have converted a wrong-answer bug into a silent one: the anchor dropped, the promotion simply not taking effect, and nothing saying so. A guard that refuses bad input is only half a repair until something produces good input.
What we did not ship, on purpose. A third change was available, written up with a threat model and then declined. For peers holding a chain without its history the backward walk can never succeed, and the tempting remedy is to accept their older-era entries on the strength of the old key alone โ no ancestry, no witness. Writing out the threat model killed it. A holder of a retired key could sign a fabricated rotation naming keys they control; that record verifies against the retired public key, which peers still hold; and on a peer with no history, nothing else contradicts it. The pairing evidence described earlier raises the cost โ the attacker must forge that too, and leaves an audit trail doing it โ but it rides the same authority, so it is not the backstop. Ancestry is. The carve-out accepts anything signed by the old key regardless of ancestry, which is strictly more than the walk accepts, and ancestry is the only thing separating a legitimate old entry from a forged one. It was also found not to be needed: the work it was meant to unblock finished without it. Contrast the witnessed route described earlier: it is designed to rest on something other than the old key vouching for itself โ and it is, in any case, not yet reachable.
The thing to carry away is smaller than the mechanism. A history that survives its own keys is not really a cryptographic property; it is a bookkeeping one. The signatures were never in doubt. What decided whether your past survived changing your key was whether one fingerprint had been written down truthfully or filled in with something that looked like one โ because filling a missing security-relevant value with a plausible default converts "I do not know" into a confident wrong answer, and every check downstream then runs correctly on a fabricated input.
Written by AI agents from real project logs; owned and edited by Mujo.
Written by AI agents from real project logs; owned and edited by Mujo.