There's a sprint in every app's life that no one writes up: the one where nothing fun gets built. No pipeline, no charts, no clever data model — just the pile of things Apple and Google quietly require before they'll put your app in front of humans.
Mine arrived all at once, a batch of must-haves standing between the working app and a store listing. It turned out to contain more genuine design decisions than I expected — which is why it gets a post instead of a sigh.
TL;DR — Store review effectively requires: a real public website (support + privacy-policy URLs that resolve), legal pages (served on the web and readable in-app), and an account-deletion path (Google additionally wants a public web page for it; Apple wants it reachable in-app). The design decisions inside: a guest-writable
DeletionRequestmodel locked to create-only (a guest model you can read is a data leak); ownership proven by email reply, never by the form; fulfilment as a written runbook rather than premature automation — with its sharpest line being about order: capture the S3 identity mapping before deleting the Cognito user, because it's unrecoverable after. And the legal pages identify "the developer of Canny Cart," not a person — a solo dev's name is PII too.
(Part 24 of Building CannyCart, a voice-first shopping app I'm building in public. Self-contained — no earlier context needed.)
The actual list
Assembled from both stores' requirements as they applied to this app:
- A public website. Store listings want a support URL and a privacy-policy URL, and they must resolve to something real. The app needed a home on the web whether or not anyone would visit it.
- Privacy policy and terms — as web pages for the listing, and readable inside the app.
- An account-deletion path. Google Play requires a publicly accessible web page where users (including ones who've lost their login) can request deletion; Apple requires deletion be reachable in-app. Two stores, two doors, one obligation.
- Assorted smaller items — data-safety declarations, permission strings that survive review (a story a later part tells in full), age ratings.
None of it optional; all of it shippable badly. The rest of this post is the handful of places where "just do the compliance thing" hid a real decision.
The public site, and the name that isn't on it
The site itself is deliberately boring engineering: a landing page and legal pages as server-rendered components — static, fast, nothing to maintain. Two decisions inside it:
The legal pages identify "the developer of Canny Cart," not a person. A solo developer's legal pages tend to leak their home identity into every scraper's dataset. The counterparty is described by role; support goes to a support address. Solo devs have PII too, and your own legal boilerplate is a strange place to donate it.
The legal documents are duplicated, on purpose. The same terms and privacy text exists twice — web pages, and a constants file the app renders natively (review frowns on "legal = a webview of our site," and offline users still deserve readable terms). One document, two renderers, and a written rule: change both together, bump the updated-date in both. A CMS would remove the duplication; a two-line convention removes the drift, at this scale, for free.
The deletion-request model: create-only, verified by reply
The Play-mandated web page lets anyone — logged in or not — request account deletion. That "anyone" is the interesting constraint: the requester is a guest, so the backend gets its first guest-writable model, and every schema instinct fires at once.
DeletionRequest: a.model({
email: a.string().required(),
message: a.string(),
status: a.enum(["PENDING", "CONFIRMED", "COMPLETED", "REJECTED"]),
requestedAt: a.datetime().required(),
}).authorization((allow) => [
allow.guest().to(["create"]), // the public page files requests
allow.groups(["SUPER_ADMIN", "ADMIN"]).to(["read", "update"]),
]),
Three deliberate absences:
- Guests can create and do nothing else. A guest-readable model is a public data feed; a guest-updatable one is vandalism-as-a-service. Create-only means the worst an abuser can do is file noise.
-
No soft-delete trio. Every user-data model in this app carries
_deletedflags; this one doesn't, because it isn't user data — it's an ops record with a lifecycle enum. Conventions deserve documented exceptions (Part 18's lesson, reapplied). - The form proves nothing. Anyone can type any email into a public page. So ownership is verified by email before anything is deleted — the request only moves forward when the address it names responds. Deleting an account because a stranger typed its email would be the worst bug this app could ship. (The verification flow later got a proper click-to-confirm upgrade with its own war story — that's a future part; the principle shipped here.)
The runbook, and the line that matters most
At solo scale, fulfilment is a written runbook, not automation: verify ownership, then delete the user's S3 objects, their rows, their Cognito account, and mark the request complete. Automating it on day one would have meant testing a destructive pipeline against zero real requests — the runbook is the honest MVP of deletion, and it's versioned in the repo like code. (In-app, immediate, server-side deletion came later and gets its own part.)
One line in that runbook earns this post its keep:
Capture the user's S3 identity ID before deleting the Cognito user — it is unrecoverable afterwards.
The storage paths are keyed by an identity-pool ID that only exists as a mapping from the Cognito user. Delete the user first and their files become orphans you can no longer attribute — un-deletable precisely when you're contractually obliged to delete them. The admin user-lookup screen surfaces both IDs side by side for exactly this reason. In destructive sequences, the order of operations is itself a data-safety feature — the step that destroys a lookup key goes last.
In-app: the other door
The app-side half of the sprint: Terms and Privacy rendered natively from the shared constants, and a Delete Account screen in the More stack that files the same kind of request under the signed-in session's email — no lost-password gymnastics, since the session itself proves the address. Same intake, both doors, one queue for the admin to work through.
What I took away
- Compliance features are features. Budget a real sprint; the "boring" list contained a schema, an auth mode, a runbook, and four honest design decisions.
- Guest-writable means create-only. Decide what a hostile stranger can do with the permission, not what your form will do.
- Possession of an email address is proven by replying from it — never by typing it.
- A runbook is the honest automation for n≈1. Version it, follow it, automate it when volume forces you to.
- Order destructive operations around your lookup keys. Whatever step destroys your ability to find the rest goes last.
- Your name is PII too. "The developer of Canny Cart" signs the legal pages; the human behind it doesn't have to.
Next up
Part 25 is the backend's quiet crown jewel: one-way replication of production data into dev with every piece of PII deterministically faked — same users, same shapes, zero real names, and push tokens that are nulled rather than masked.
What did your compliance sprint force you to build — and did any of it turn out to be secretly load-bearing?
Top comments (0)