← back

Nothing leaves until you tap Save

#product-decisions#privacy-by-design#compliance#responsible-ai#infrastructure

Adding reminders to a private journaling app meant deciding what a notification is allowed to know, and proving the answer before anyone could opt in.

Today the companion learned to send two notifications: an evening nudge to write, and a morning note that the intention set the night before is ready. Reminders are the most ordinary feature in the category. In this app they were not ordinary at all, because every design choice ran into the same wall: the journal is encrypted on the writer's device, and a notification is a message the server composes. Something has to give, and I wanted to be precise about what.

What a notification is allowed to know

The obvious version of the morning notification puts the intention itself on the lock screen. That is the version the feature wants. It is also the version that quietly moves a sentence the writer typed, often with a name in it, from an encrypted column into plaintext the server holds, and then onto a screen anyone near the phone can read.

I split the decision into three options and made the privacy boundary the deciding line. The server could store the intention readable and compose the message. Rejected, because it weakens the app's central promise about who can see an entry, and the whole product is organized around that promise. The phone could encrypt the notification body itself the night before, so the server relays a message it cannot open. That is the design I want, and it has no shipped precedent I could find, so it gets its own bounded experiment before I commit. Or the notification can say nothing at all: "the intention you set last night is ready," with the text appearing only when the app opens and decrypts it on the device.

I shipped the third one first, on the same infrastructure the second one will use. It misses the stated goal by exactly one tap, and it exposes nothing. The lesson I took from the ordering: build the content-free version on the pipe you will need anyway, and let the ambitious version earn its place with evidence instead of confidence.

Even the content-free version needed one new fact on the server. A morning notice that says an intention is ready must not fire when no intention was set, and the server cannot see inside the encrypted reflection to check. So the app now records one date: which day the last intention is for. Not the text, not a summary, one calendar date. I treated that as a real disclosure and wrote it into the policy, because a date is metadata and metadata is data. The server learns "this person set an intention for Wednesday" and nothing else.

Reversing my own decision on verified grounds

Three days ago I decided this app would have no service worker. The install-to-home-screen work had proven the browser no longer needs one to install, and every service worker I have watched in production eventually caches a stale version of something. The decision was right on the evidence I had.

Push notifications need a worker. There is no other context alive when the app is closed. So I reversed the decision, and the interesting part is how narrowly. The worker handles exactly two events, a push arriving and a tap on it, and has no fetch handler at all. It caches nothing and serves nothing. A test asserts those absences so a later edit cannot quietly grow a cached shell. And it registers only when someone turns reminders on, so a person who never touches the feature never gets a worker.

Reversing a decision is cheap when the original reasoning was recorded. I did not have to relitigate whether workers are risky in general. I had to check whether the specific risk I had named applied to a worker that cannot fetch. It does not.

Consent as the notice

The privacy policy had to change. Reminders add a new recipient of personal data, the push service run by the browser's maker, and new stored settings including the device's time zone. The policy promises notice before a new recipient takes effect, and thirty days of review for material changes. The question was whether an opt-in feature counts as material.

I argued no, and I want to show the reasoning rather than assert it. The feature is off for everyone. No data goes anywhere new until a person opens Settings, reads what the screen says, and taps Save. That tap is notice, given before the effect, to the only person affected. Publishing the updated policy the same day the feature ships keeps the promise. The strict reading, holding the feature for thirty days, protects against a change that reaches people who did not choose it, and this change cannot reach anyone who did not choose it.

The founder made the call for the opt-in reading, with a one-line mention in the next beta email as cheap insurance. The mechanics of what the push service actually receives, an opaque address and an encrypted body the server composed from a fixed sentence, went into the attorney notes rather than the policy. The policy says categories. The note says how.

One small catch on the way: the copy gate flagged a two-word phrase inside a sentence I had written about the browser being the sole reader of a notification. The gate is a list of words that carry statutory weight in privacy claims, and it does not care about context. I reworded rather than argued, because a gate that lets me argue my way past it is not a gate.

Proving the chain, not the function

The pipeline has more hops than it looks: a scheduler ticks every minute inside the database, a guard decides whether anything is due, secrets are read from a vault, a request crosses the gateway into a function, the function encrypts and signs a message, and a push service on the other side of the internet accepts or rejects it. I proved the function first, on a local copy of the whole stack, against real push-service endpoints with made-up device tokens. The services answered "gone," the function pruned the dead subscriptions and marked the day, and a second call in the same minute found nothing to do. That felt like done.

It was not done. An hour later I ran the scheduled chain end to end on the same local stack, and only that run exercised the guard that skips the call when nothing is due, the vault reads, and the gateway hop. All of them worked. All of them were also untested until then, and a hop that was never invoked is a hop that was never tested. The function proof had told me the engine ran. The chain proof told me the car moved.

The same principle caught a real problem an hour after that. The function ships as two files, and the deployed version reported itself healthy with a version number. When I read the deployed files back one by one, the second file was empty. The version number said the deploy happened. Only the file said what had deployed.

Mental Models

Ship the content-free version on the pipe the ambitious version needs. The infrastructure is the expensive part. Let the version that exposes nothing prove the pipe, then let the version that exposes something earn its way in with evidence.

A date is data. When the server needs one fact to behave honestly, record that one fact, disclose it, and stop. Metadata minimization is a design discipline, not a footnote.

Reverse decisions narrowly. When new requirements overturn a prior call, check whether the specific risk you named still applies, and build the smallest thing that satisfies the requirement while keeping that risk out.

Consent that precedes the effect is notice. An opt-in feature that touches no one who did not choose it can ship with its policy change. The thirty-day hold is for changes that reach people who did not ask.

A hop that was never invoked was never tested. Prove the whole scheduled chain, not the function at the end of it, and read back what deployed rather than trusting what the deploy reported.