Designing a draft store that can't hold plaintext
Why I retired a plaintext draft-saving shortcut instead of patching it, and built a store where holding readable text is structurally impossible.
Today's main decision looked like a step backward. I chose to retire a draft-saving feature that works, and build its replacement from scratch. The feature was a small convenience, and it was the one place in the app where sensitive data sat in plain, readable text.
The shortcut that broke the rule
Duskglow runs on one rule about user data. Everything gets encrypted on the person's own device before it goes anywhere, and the key that unlocks it lives on the client. That rule is the whole privacy posture in a sentence.
There was one exception. A draft-saving shortcut wrote the in-progress conversation to the browser's local storage as plain text, so a half-finished entry would not vanish if a save failed or the network dropped. Useful. It had been there a while, quietly doing its job.
It was also a band-aid sitting outside the encryption rule. Readable text, in storage, in a product whose whole privacy model says your writing is encrypted before it lands anywhere. That is the kind of gap that does not announce itself. It works fine right up until it matters.
Patch it, or replace it
Two ways to close the gap. Retrofit the shortcut: encrypt what it writes, leave it where it is. Or supersede it: retire the shortcut and build a store designed for this job from the ground up.
Here is the part worth slowing down on. Earlier in the project, retrofitting in place would not even have worked for everyone, because not every user had an encryption key loaded at that point in the flow. That constraint had quietly dissolved. A recent architecture change means every user now has a key loaded right after setup, so retrofit had become possible for all of them. I caught myself about to weigh the decision on the old constraint, the "is it even feasible" question, when feasibility was no longer the question at all. The real question was which design I would rather live with.
I replaced it. The old shortcut carried baggage I did not want to inherit. It lived only in local storage, it held a single slot, and each save clobbered the one before it. Encrypting it in place would have carried all of that into the new world. Building fresh let the new store be shaped for what it actually needs to do, which is hold an in-progress draft that survives, on the server, encrypted.
That last point matters more than it sounds. The bug this whole effort exists to fix is conversations disappearing when a phone locks and the browser quietly discards the tab. Local storage does not save you there, because if the tab dies, the local copy can die with it. Putting the draft on the server means it is still there when the person comes back, even if their device gave up entirely.
Making the unsafe state impossible
The decision I care most about is small and structural.
I did not want to rely on good discipline to keep plain text out of the new store. Discipline fails. Someone adds a code path in six months, forgets the rule, writes raw text into the table, and nobody notices until it is a problem. So I pushed the rule down into the database itself. The table now carries a constraint that rejects any row that is not shaped like encrypted data. If the app ever tries to write readable text into a draft, the database refuses the write. Not a warning. Not a log line for someone to find later. A hard no, every time.
This turns a rule you have to remember into a rule that cannot be broken. Monitoring tells you after the fact that something already went wrong. A constraint means the wrong thing never lands in the first place. And the check only looks at the shape of the data, never the contents, so it protects the encryption without ever needing to see inside it.
The unglamorous decisions that carry the weight
A few smaller schema choices do more work than they look like they do.
One row per user. A draft is the current in-progress thing, not a history of every attempt, so the store keeps exactly one per person and a new save replaces it. Less to track, and fewer ways to get it wrong.
Deletes cascade. The draft is tied to the account so that when someone deletes their account, the draft goes with it automatically. This is not a nicety. Account deletion that leaves stray data behind is a compliance failure, and "remember to also delete the drafts" is exactly the sort of cleanup step that gets forgotten in code. Letting the database enforce the cascade means it cannot be forgotten.
The server sets the timestamp, not the client. Drafts have to sync across devices, which means the system needs to know which version is the newest one. If it trusted the device's clock and that clock was wrong, last-write-wins would happily pick the stale draft and overwrite the good one. So the server stamps every write itself. The client does not get a vote on what time it is.
Mental models
Replace the band-aid, don't encrypt it. When a quick fix lives outside your core rule, patching it in place inherits all its limits. A clean design costs more today and far less every day after.
Make the unsafe state impossible, not monitored. A constraint that refuses bad data beats an alert that reports it after it has already landed. Push the guarantee down to a layer that cannot be skipped.
Re-check the premise before you weigh the options. I almost decided this on a constraint that had already gone away. The options looked the same. The right answer had moved.
The boring schema decisions are the load-bearing ones. One row per user, cascading deletes, a server-set clock. None of it is exciting, and all of it is the difference between a store you trust and one you keep having to babysit.