Munchable's privacy notice makes a short, checkable promise. The conditions you pick, the ingredients you have marked as personal sensitivities and the history of what you have scanned are written to storage on your own phone and stay there. We cannot read any of it, we cannot recover it for you, and there is nothing in it to hand over if anyone ever asked. You can read the whole thing at munchable.app/privacy, where it also says this:
Uninstalling the app, clearing its storage, or deleting your account all remove it.
Uninstalling is the operating system's job. Clearing storage is the operating system's job. The third one is ours, and in a React Native app it comes down to a hand-written list of strings.
The whole erasure is eight keys and a swallowed error
export async function wipeLocalData(): Promise<void> { try { await AsyncStorage.multiRemove([ 'munchable-profile', // health profile 'munchable-history', // scan history + food log 'munchable-products-v1', // productCache (pre-v2 key, still cleared) 'munchable-products-v2', // productCache (pre-v3 key, still cleared) 'munchable-products-v3', // productCache (current key) 'hints:sent', // legacy, no longer written 'taxonomy:overlay', 'taxonomy:meta', ]); } catch { // Best-effort: a storage error must not block deletion. } } Two decisions in there are worth defending before the bug.
It never throws. Account deletion already succeeded on the server by the time this runs: the subscription is cancelled, the rows are gone, the identity is gone. If multiRemove fails on a device with a full disk, the right outcome is still "your account is deleted", not an error screen in front of a user who no longer has an account. The session itself lives in SecureStore and is cleared separately, which is the one piece of this that must work, and it does work or the user is still signed in and can see that for themselves.
And it lists keys that nothing writes any more. hints:sent has not been written for months. munchable-products-v1 has not been written for longer. They stay because an erasure runs on a device that may have been carrying this app since before those keys were retired, and a key nobody writes is not a key nobody has.
Why the cache key has a version in the first place
The product cache exists for one unglamorous reason: a product you scanned should survive the app restarting. It holds 24 entries, pruned by recency, which is a little more than the twelve recent scans the app will show you, so a recall always hits. It holds public product data only. Never conditions, never verdicts, never photos. Nothing in it is about a person, which is what makes it safe to leave on a shared device at all.
The key is munchable-products-v3 rather than munchable-products because of a specific failure that is easy to get wrong twice.
An entry written by an older build rehydrates with any new field simply absent. The temptation is to treat that as a cache miss. It is not: the object is there, it parses, it has a barcode and a name, so every code path accepts it. The fields that were added since are undefined, and undefined is a value, so somewhere downstream a check that means "do we know this?" gets the answer "no" when the truthful answer is "we have not asked".
The first time this bit, the missing field was the one recording whether Munchable already had a name for a barcode. Absent read as unnamed. So after an app update, the capture screen told people that nobody had added this barcode yet, for products we did have names for, and then asked them to type a name we already had, and then took it. Nothing crashed. Nobody filed a bug. The screen was wrong and perfectly confident.
The fix is a version suffix bumped on every change to the stored shape, so an entry from an older build is not rehydrated at all. It is a one-character edit and it is correct. It is also an edit that happens in a different file from the list above.
The drift
Bump STORE_KEY from v2 to v3 in productCache.ts and everything you would think to check still passes. The app writes v3. The app reads v3. Old entries are ignored, which is the point. Types are fine, because the key is a string literal in one module and nothing else refers to it.
And account deletion now clears v1 and v2, carefully, and leaves the live cache sitting on the device.
The practical damage is at the small end. It is 24 products, no health data, nothing personal in any of it. But "deleting your account removes it" is a sentence in a privacy notice, and the gap between that sentence and the code is exactly the kind of gap nobody notices until someone checks, because there is no symptom. The user taps delete, the screen says deleted, and it is deleted everywhere they can see.
The test reads both files as text
The obvious test imports both modules and compares. It cannot: both pull in AsyncStorage, which wants a native host that node --test does not have, and the whole shared test suite is plain Node with no native runtime anywhere in it. The options are to stand up a React Native test environment for one assertion, to mock AsyncStorage and test the mock, or to stop treating these as modules and treat them as what they are, two files that have to agree about a string.
const read = (path: string) => readFileSync(new URL(path, import.meta.url), 'utf8'); test('account deletion clears the product cache key the app actually writes', () => { const key = /STORE_KEY = '([^']+)'/.exec(read('../productCache.ts'))?.[1]; assert.ok(key, 'productCache.ts no longer declares STORE_KEY as a literal'); assert.match(read('../wipe.ts'), new RegExp(`'${key}'`), `wipe.ts does not clear ${key}`); }); Twenty-two lines including the comment. It fails today, with the key named in the message, which is the only output worth having.
The first assertion is the one I would push back on in review and then keep. A regex over source is a brittle technique, and the brittleness is usually invisible: refactor STORE_KEY into a template string or a shared constant and the regex quietly matches nothing, key is undefined, and a test whose entire job is to catch drift starts passing unconditionally. So it asserts its own premise first. If that line ever fails, the message says the technique has expired rather than the code is fine.
What it deliberately does not do
It does not check the other seven keys. A test that read every AsyncStorage.setItem call in the app and demanded each key appear in the wipe list would be a better test in principle and a worse one in practice: it would need to understand computed keys, it would fail on the three retired keys that are in the list on purpose and written nowhere, and the first time it was wrong somebody would delete it.
The versioned key is the one covered because it is the one that moves on a schedule. The risk here is not that an engineer forgets erasure exists. It is that a correct, tiny, well-reasoned edit in one file silently invalidates a line in another, and the edit will happen again at v4.
The other thing it does not do is prove the promise. The promise is that the health data is on the device at all, and you cannot assert that from inside the app. You assert it from the other end: the data export at the account screen returns every row we hold for you, and the reason it has a note field explaining what is missing from it is that the interesting half of your data is not ours to export. That one is its own post. Start with the privacy notice, which lists it per category, with the lawful basis, including the categories where the honest entry is that there is nothing to list.