Munchable scans a packaged food and tells you whether it fits your gut condition. The conditions are at munchable.app/conditions; the privacy posture this post is about is written down at munchable.app/privacy, where the short version is that the conditions you pick, your sensitivities and your scan history are held on your device and never sent to us.
A recent addition to the app is a scan history with a light food log on top of it: every product you checked, by day, with room to say how you felt afterwards. It is the one screen in the product where the user writes about themselves.
It has no server component at all. Not "encrypted at rest", not "stored in a private table". There is no table.
What the architecture is buying
Three feelings and a one-line note, keyed to products you ate, is health data under any reading of the term. The engineering question is not whether to protect it, it is where to put it so that protecting it is not an ongoing promise we have to keep.
A device-only store is unusual enough that it is worth being honest about what it costs:
- No backup. New phone, new log.
- No multi-device.
- No analytics on it, ever, including the aggregate kind that would genuinely help us build a better product.
- No server-side features built on it. No "your top triggers this month" email, because the data for it does not exist anywhere we could run a job.
What it buys is that the promise is structural rather than procedural. We cannot leak it, cannot be compelled to hand it over, cannot accidentally log it, and cannot have a future teammate write a well-meaning query against it, because none of those have an object to act on. For a product whose entire proposition is answering a health question without learning your health, that trade is not close.
Mechanically it is a persisted store and a wipe list:
export const HISTORY_STORAGE_KEY = 'munchable-history';
/**
* Health data by any reading, so it lives here and nowhere else: never synced,
* never exported by the server's account export, cleared with the rest of the
* device on account deletion.
*/
export const useHistory = create<HistoryState>()(persist(/* ... */));
The GDPR export has to say the absence out loud
This is the part I did not anticipate. A data subject access request produces a JSON file of everything we hold. Ours is short: an account row, a consent record, subscription state, and any product labels the user contributed.
A user who opens that file looking for their food log and does not find it has learned nothing about where it is. Silence in an export reads as an omission, and an omission in a privacy feature reads as a lie. So the export carries a sentence about the data it does not contain:
const payload = {
exportedAt: new Date().toISOString(),
account: { id: user.id, email: user.email ?? null },
note:
'Your conditions, sensitivities, and scan history are stored only on your device ' +
'and are never sent to our servers, so they are not part of this export.',
// ...
};
Account deletion has the mirror-image problem. The server rows go, and the device still holds the log, so deletion clears local storage by key as part of the same flow. The storage key is listed in one place for exactly this reason: a local-only store that is not in the wipe list is a privacy feature that turns into a liability the first time somebody deletes their account.
The log interprets nothing, on purpose
The vocabulary is three feelings and one line of up to 140 characters. Fine, a bit off, rough.
An earlier build had a card at the top of the screen that looked for patterns across entries: the ingredients that showed up most often before a rough day. It was the most impressive thing on the screen and it is gone, because of what it implicitly claims. A screen that correlates two user-entered fields in a gut health app is doing clinical inference with an n of one, no controls and no timeline, and presenting it in the same interface that elsewhere cites published guidance. The user cannot tell which of those two things they are reading.
So:
/**
* The log is a plain record: the verdict says what the product was, the feeling
* says how the day went, and the screen draws no conclusion from the two.
* Whatever pattern is there is the reader's to see.
*/
Removing it also removed the pressure to be clever later. There is no summary card to improve, no model to train, no threshold to tune. A record that interprets nothing cannot be wrong about you.
Four small decisions that make a local log feel right
The verdict is frozen at scan time. The entry stores the verdict as it was when the product was checked. Our ingredient data improves weekly, so a log that recomputed verdicts on render would rewrite the user's own history under them. A record of what you were told is more useful than a record of what we would say now.
export interface HistoryEntry {
/** Stable per check, so a feeling attaches to the right one of two scans of one pack. */
id: string;
/** The verdict at the time of the check, so the log reads as it was, not as it would be now. */
verdict: VerdictValue;
at: number;
feeling?: Feeling;
note?: string;
}
A rescan inside an hour is the same check. You scan a pack in the aisle, tap it again from the recent list, photograph the label because it was missing. That is one event, and a log that shows it three times is a log of the app's behaviour instead of yours. Repeats inside the window refresh the entry in place and keep whatever feeling was already attached to it.
Days are local calendar days. A check at 23:30 files under today, not tomorrow, which means the grouping key is built from local date parts rather than from anything that passes through UTC.
The 500 entry cap is a cap on a scroll. A year of daily scanning fits, the screen renders the whole thing without paging, and nothing in the app pretends this is a dataset.
Two coordination problems, since nobody reviews local-first code
A device-only store trades server problems for client problems, and both of the ones I hit are about state written by a previous version of the app.
The first: before the log existed, the profile store kept the last twelve scans for the hub's recent list. Those should become the first entries of the log, once. Two persisted stores hydrate from disk on independent schedules, so the migration cannot assume either has finished, cannot run twice, and has to leave the legacy field empty afterwards so it cannot import twice. It subscribes to whichever store is later and only then runs.
The second: everything in local storage was written by a build you no longer control, which makes it untrusted input. The rehydration step filters entries that lack the fields the current screen renders and drops feeling values the current build does not recognise, rather than trusting the shape. I wrote about that failure mode in more detail in storage written by an older build of your app is untrusted input, and a log is the clearest case for it: the data has no server-side schema anywhere to validate it against, so the only validation that will ever happen is the one you write at the read.
Look at it
The log is behind sign-in because it is on your device, which is the whole point. Sign in at app.munchable.app, scan two or three things from your cupboard, then open History from the hub and attach a feeling to one of them with the network tab open. It stays silent: the scan itself talks to our API, the thing you wrote about yourself does not.
The posture it implements is at munchable.app/privacy, and what the app reasons about at all is at munchable.app/conditions.
Top comments (0)