DEV Community

Ibukun Demehin
Ibukun Demehin

Posted on

The compliance sprint nobody blogs about

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 DeletionRequest model 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"]),
]),
Enter fullscreen mode Exit fullscreen mode

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 _deleted flags; 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)