Munchable is a barcode scanner for people with gut conditions, and the thing that makes its privacy notice short is that the health data never arrives: the conditions you pick, your sensitivities and your scan history are on the phone, and the verdict is worked out there too. The policy is at munchable.app/privacy, and the sentence it leads with is that we hold an email address, a subscription record and any labels you contributed.

Two route handlers have to make that sentence checkable: one that hands you everything we hold, and one that erases it. They live in sibling folders, they were written months apart, and they disagree about the schema.

The export, in full

GET /api/export-account is five queries in parallel and one JSON response.

const [ent, consent, rewards, contributions, feedback] = await Promise.all([ db.select().from(entitlements).where(eq(entitlements.userId, uid)).limit(1), db.select().from(userConsent).where(eq(userConsent.userId, uid)).limit(1), db.select().from(contributionRewards).where(eq(contributionRewards.userId, uid)), db.select({ barcode: ..., ingredientsText: ..., createdAt: ... }) .from(catalogProductRevisions) .where(eq(catalogProductRevisions.contributorId, uid)), db.select().from(catalogProductFeedback).where(eq(catalogProductFeedback.userId, uid)), ]); 

It answers 401 to a signed-out caller and to an anonymous session, serialises with JSON.stringify(payload, null, 2) because a human is going to open it, and sets a content-disposition so it lands as a file rather than a wall of text.

The field I want to point at is not a table. It is this:

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.', 

An export of almost nothing looks broken. Someone who scans twenty products a week opens this file, sees a subscription row and two contributions, and reasonably concludes the export is incomplete. So part of the payload's job is to explain its own smallness, in the file, where someone reading the file will see it. Our privacy notice says the same thing, but a notice is not what lands in the Downloads folder.

The erasure, and the list it uses

POST /api/delete-account is a different shape: cancel the Stripe subscription, then one transaction, then delete the identity.

await tx.delete(contributionRewards).where(...); await tx.delete(catalogProductFeedback).where(...); await tx.update(catalogProductRevisions).set({ contributorId: DELETED_CONTRIBUTOR }).where(...); await tx.delete(userConsent).where(...); await tx.delete(entitlements).where(...); await tx.delete(supportMessages).where(inArray(supportMessages.ticketId, /* subquery */)); await tx.delete(supportTickets).where(...); 

Seven tables. Six deletes and one update: contributed revisions are re-pointed at a fixed sentinel id rather than removed, because the catalogue rows that reference them are not personal data and should not vanish from other people's app when one contributor leaves.

The route also refuses to start if it cannot finish. Without the Supabase service-role key it cannot delete the identity itself, so it answers 501 rather than deleting the rows and leaving the login, which would be a half-erasure that returns 200.

The diff between the two lists

Side by side:

table erased exported entitlements yes yes user_consent yes yes contribution_rewards yes yes catalog.product_revisions anon 3 of 18 columns catalog.product_feedback yes yes support_tickets yes NO support_messages yes NO 

The two tables the export does not read are the two the erasure route is most emphatic about. Its own comment:

// Support history goes with the account. A ticket body is whatever the // person typed, so it is the most sensitive free text Munchable holds and // erasure has to take it. 

One route calls it the most sensitive free text we hold. The other does not include it in a right-of-access response. Both statements are in the same repository, in the same folder, and both are mine.

Nothing malicious happened here. The order of events was: write both routes, add a support desk months later, and remember to extend the erase list because erasure is the one that frightens you. The export list is not frightening. It is also not checked: there is no type, no test and no shared constant relating either route to the set of tables that hold a user key. Twelve tables in the schema, six with a user column, one more reachable through a foreign key, and two hand-written lists that have to be kept in step by memory.

The column gap, and why it is the opposite mistake

The second row of that table is its own small story. Four of the five export queries are select() with no projection, which sends every column. The fifth names three columns of eighteen.

So the export has two opposite defects at once.

Where it uses select *, it hands the user columns that are about our operations rather than about them: Stripe customer and subscription identifiers, a coupon id, a reward status, an error string from a failed credit. That is defensible for an access request, where hiding a field is the worse error, and it is a maintenance hazard: a moderation column added to the feedback table next month is in the next export with nobody deciding that.

Where it projects, it is too thin. A contributed revision holds the product name and brand the contributor typed, the nutrition they captured, the allergen line as read. All of that is their contribution, and after erasure anonymises the row it is the only copy they could have kept. The export gives them a barcode, the ingredients text and a timestamp.

A projection is a decision about what somebody is entitled to see about themselves. Writing one inline, in a route handler, in the middle of a Promise.all, is not where that decision should live.

There is a third gap, and it is in the schema

support_tickets.user_id is nullable, deliberately:

 * First, `user_id` is NULLABLE. Munchable accepts support mail from people who * have no account (a prospective subscriber, someone replying from an address * that is not their sign-in address), and losing those messages because they do * not join to an account row would be worse than storing them unattributed. 

I still think that is the right call. It also means attribution is best effort, so "delete everything about me" deletes the tickets that happen to carry your user id and leaves the one you sent from your work address. Erasure by foreign key is only ever as complete as the key.

What this is actually asking for

One list, read twice. A personal-data map in the schema layer: per table, the column that identifies the person, and what each of the two rights does with it, which is erase, anonymise or keep. Both routes iterate the map instead of spelling out their own version of it. Then one test, of the kind that is a grep in a trench coat but still catches the only bug that ever happens here: every table with a user-identifying column appears in the map, or the suite fails.

That test would have failed the day the support tables landed, which is the day it needed to fail.

If you want to see the two buttons this is all behind, they are in the app, on the profile screen, next to each other, which is how I noticed the two routes did not agree. The policy they implement is the short version at the top of munchable.app/privacy, and the condition guides the whole product exists for are at munchable.app/conditions.