← back

A letter the server cannot open

#privacy-by-design#client-side-encryption#product-decisions#debugging#responsible-ai

Shipping a morning notification that shows the user's own words without the server ever holding them, and the two failures that made it real.

Yesterday I decided the morning notification should carry the intention a user set the night before, in their own words. Today that shipped. The interesting part is not that the text shows on the lock screen. It is that the server that sends the notification never has the text, and I can now say that from a test on real infrastructure rather than from a diagram.

The shape of the problem

A push notification is a small message the server hands to the phone's push service, which hands it to the browser. Every push body is already sealed to the browser's keys before it leaves the sender, so the push service sees nothing. The usual design has the server compose the message and seal it. That works for "your journal is ready." It does not work for "call Mom about Apple products," because now the server has composed a sentence the user wrote inside an encrypted journal.

The alternatives were narrow. Keep the notification content-free and show the text only on tap, which misses the point of a morning reminder. Store the intention in readable form on the server so it can compose the push, which I had rejected the day I logged the feature. Or move the sealing step to the phone: at bedtime, the device that recorded the intention builds the notification, seals it to its own push keys, and stores the sealed record. In the morning the server forwards that record with its sender signature and deletes it. The server holds a fixed-size blob it cannot open, which is exactly what the push service already holds for every notification.

That third option had no shipped precedent I could find, so it got a spike before it got a commitment. The spike passed yesterday. Today was the build.

Make the database refuse what the design forbids

The new table holds one sealed record per device per day. Two properties mattered more than the columns.

The first is that a record cannot be attached to someone else's device. Row-level security already keeps users inside their own rows, but I wanted the rule to hold even against a guessed identifier, so the foreign key references the device row by its id and its owner together. The database itself refuses a row that pairs one user with another user's device. No trigger, no application check, nothing to forget.

The second is that plaintext cannot be stored by accident. The sealed record is always the same length, and it is written in an alphabet that has no room for braces or quotes. So the column's check constraint admits exactly one shape: that alphabet, that length. A future bug that tried to store the readable notification would fail at the database, the same way our draft and recovery envelopes already fail if handed plaintext. Fixed length also means the server learns nothing from size.

The principle: when a privacy property is load-bearing, put it where it cannot be skipped. A comment says what should happen. A constraint says what can.

The gate earned its keep before the paste

Before any schema change reaches production, it replays on a local copy of the database and a battery of tests runs against real sessions and real row-level security. Then the battery runs again with security switched off, with every isolation expectation inverted, to prove the tests would notice a leak.

The first run failed on every insert into the new table. The length check I had written used a regular-expression repetition count, and the database engine caps that count at 255. My pattern asked for 1,502. Every write was refused with an obscure error about an invalid expression. I would not have caught that by reading the file. The gate caught it in seconds, the constraint became a length comparison, and the paste that reached production was the one that had passed.

This is the whole argument for a replay stack on a solo project. The cost of the gate is a few minutes per change. The cost of a constraint that refuses every write in production is a broken feature and a second paste under time pressure.

The failure that was not the feature

The production test failed on the first attempt. The server reported one push sent, one refused. The refused one was the sealed record, and the push service had answered with a permission error.

My first instinct was to suspect the new code path. The log said otherwise. A permission error from a push service means the sender's key does not match the key the subscription was created with. The day before, the spike had subscribed this same phone to the preview site with a throwaway key. A browser keeps one subscription per site. Today's save found that subscription already in place and reused it without asking which key had made it, so production could not sign for it.

The fix on the phone was a minute. The fix in the code was a comparison: when a subscription exists but was made with a different sender key, drop it and subscribe again. That same hole would have opened on any future key rotation, so it belongs in the product, not in a note.

The second attempt reported one sent, one sealed, zero failed. The phone showed the intention in my own words, from a body the server had forwarded unopened.

Mental Models

Put the guarantee where it cannot be skipped. A constraint that refuses the forbidden shape is worth more than any amount of code that promises not to produce it.

Replay before you paste. A local copy of production that runs the real tests turns "this should work" into "this did work," and it finds the errors that reading cannot.

Read the error before you blame the new code. The failure in a new feature is often an old assumption the feature just walked into.

A subscription is bound to the key that made it. Presence is not enough. Check identity, or a stale credential will look healthy right up to the moment it is used.