A re-key guard with three answers instead of two
Designing recovery for when a user loses their encryption key, where both resetting the key and locking them out are the wrong move.
Most of today came down to one decision with two obvious answers, both of them wrong. When someone loses access to the key that unlocks their encrypted data, the app has to do something. The two somethings that come to mind first each fail the person in their own way, and the move I landed on is a third one that sits between them.
Two ways to fail someone
By design, the key that decrypts a person's drafts lives on their own device, not on our servers. That is good for privacy, and it creates a genuinely hard problem. If that key ever becomes unreachable, what should the app do?
One easy answer is to make a new key and move on. The user gets back in, the app keeps working, the lockout is over. But if there was any encrypted data tied to the old key, that data is now scrambled for good. You traded a locked door for a burned house.
Reverse it, and you get the second easy answer. Never make a new key, protect the data no matter what. Except if the key really is gone, you have now locked the person out of their own account permanently, with no door left to open. Protecting data the user can never reach again is not protecting them.
Each answer is defensible on its own. Both are wrong as a blanket rule, which is the tell that the real design needs more than two branches.
A third path
What I built today routes the decision three ways.
If the app cannot even tell whether the key is recoverable, it does nothing. There is a local cache that holds the key, and there is a difference between that cache saying "not here" and that cache throwing an error. A clean miss is information. An error is the absence of information. The old code treated the two the same, which meant a momentary glitch reading the cache could look exactly like a key that was truly gone. So the first branch is a hard stop. On a cache read error, never re-key, just wait and retry. Treating "I could not check" as "it is not there" is how you delete data by accident.
If there is recoverable encrypted data sitting there, the kind the current key would open, the app stops and asks the person to confirm before it replaces the key. Re-keying would orphan that data. That is a choice the user should get to make with their eyes open, not something the app does quietly on their behalf.
And if the key is genuinely, definitively gone, the app does re-key and let them back in. No trap. When there is truly nothing left to recover, holding the door shut helps no one.
Say it plainly and it sounds almost obvious. Don't act when you are not sure. Ask when there is something to lose. Only reset when there is nothing left to protect. It did not feel obvious while I was untangling which state was which.
Reading the code before trusting my own plan
Before building any of that, I went and read what the app actually does today instead of trusting the spec I had written for it weeks ago. Good thing I did.
My spec said the save flow encrypts the finished entry. The code does not. Finished entries still save as plain text right now, and the encrypted path I was about to build is for drafts only. If I had built the guard against the spec, I would have written protection for a data flow that does not exist yet, and convinced myself it was done.
It also named a server function as the main save path. There is no such function. The save is a direct write from the client. Another assumption that had quietly gone stale, another place where building against the plan would have meant building against something imaginary.
I keep relearning this one. A spec is a snapshot of what I believed the day I wrote it. The code is what is true now. When the two disagree, the code wins, and the gap between them is usually the exact spot where a bug was waiting to move in.
The bug that only showed up when I used it
The last catch of the day did not come from the test suite. It came from sitting down and using the feature like a person would.
Picture the flow. The app saves your draft as you go and deletes it the moment you finish the entry. Fine, until two of those events fire almost on top of each other. A background save kicked off a half-second before I hit finish and landed a half-second after the delete. The draft I had just deleted came back from the dead.
A draft resurrecting itself. The delete ran, a late save re-inserted the very draft that delete was supposed to remove, and the store ended up holding something that should have been gone. In a privacy feature, a record that refuses to stay deleted is not a small bug.
The fix lines every draft write and the delete up on a single chain, one strictly after another, so the delete always runs last and there is no gap for a late save to slip into. What I want to underline is how I found it. No unit test caught this. I caught it because I sat there sending messages and finishing an entry and actually watched what happened to the data. Timing bugs between two writes hide from tests that check one thing at a time, and they surface the second you click through the real flow at real speed.
Mental models
When both obvious answers are wrong, look for a third branch. "Reset the key" and "lock them out" were each wrong on their own. That was the signal the design needed three paths, not a pick between two.
An error is not an absence. "I could not check" and "it is not there" are different states, and collapsing them is how you destroy data you meant to keep. Make the system tell the two apart.
Trust the code over the plan. A spec records what you believed then. Read the current code before you build on top of it, because the stale assumptions stay invisible until they cost you.
Some bugs only exist in motion. A race between two near-simultaneous writes will pass every unit test and then surface the first time a human uses the feature at human speed. Click through the real thing.