The /erase Contract

Backend handoff · deletion safety

The /erase Contract

What the node side must hold to while the Flutter delete fix rolls out — and the one guarantee that, if broken, deletes user data permanently and silently.

18 Aug 2026 routes/user.js Temporary — remove on full rollout

The rule

Non-negotiable

Never answer 200 for a profile that is still alive. A fixed client takes that as proof the delete happened and erases its own retry record — the delete is then gone for good, with nothing left anywhere to replay it.

Everything else in this brief is a means to that end. The old client shrugged off a false 200 and kept retrying, so the delete still landed eventually. That safety net is exactly what the fix removes.

Why the same response now means two things

The client no longer retries blindly — it now trusts what you tell it. During rollout there are two populations in the field with opposite failure modes, and you cannot tell them apart: the client sends no version header of any kind.

So every policy has to be safe for both columns at once.

You answer Old client (in the field now) Fixed client
200 · really deleted CorrectClears its queue entry. CorrectPurges the tombstone. Done for good.
200 · still alive SurvivableBelieves it, but a later pass re-sends anyway, so the delete still lands. Data lossPurges the tombstone and never asks again. The profile stays alive forever and the user believes they deleted it.
404 · not found Self-healsReads the body, treats it as done. Self-healsTreated as success — the outcome was already true. Keep the body wording.
429 / 503 StormRetries forever. Counter is in-memory, so it resets on every app launch. This is the loop we are trying to stop. CostlyBurns one of 3 permanent attempts. Three throttled responses and the delete is abandoned for good.

Correction to earlier advice

I previously said a 429 was the safe way to shed load. That is only half right — it avoids silent loss, but on a fixed client it consumes the permanent retry budget, so three throttled responses drop the delete anyway. Do not throttle live deletes with any error status. Shed replays instead.

The approach: name the accounts, don't change the policy

Rather than retune the global rate limit — which risks every user to contain a handful — pin the containment to the specific accounts that are actually stuck, and delete the code when the rollout completes.

This is the right call because the stuck devices are replaying deletes that already succeeded. For those accounts the profiles are already isDeleted, so short-circuiting them is a no-op by definition: there is nothing alive to lose. The blast radius is exactly the listed accounts and nothing else.

Finding the set

I don't have the uids — pull them rather than working from the support tickets alone, since the handoff notes 68% of affected accounts burst on a single day and were never noticed. The signature is high call volume against few distinct profiles.

// /erase log lines carry uid + profileId. Over a 24h window, the ratio
// total_calls ÷ distinct_profileIds is the tell.
//   healthy user  ≈ 1.0   (each profile deleted once)
//   stuck device  ≈ 8.0   (live incident: ~3,348 calls / ~423 profiles)

// Cross-check a candidate before adding it — every id it is replaying
// should already be deleted:
db.users.find({ uid: "<uid>", isDeleted: true }).count()
db.users.find({ uid: "<uid>", isDeleted: { $ne: true } }).count()

An account qualifies only if the profiles it keeps erasing are already deleted. If a candidate still has live profiles being erased, it is not this bug — do not add it.

The shape of the check

Keep it one obvious block, easy to find and delete. No config, no database table, no feature flag — those outlive their purpose. A literal list with a removal note is the point.

// TEMPORARY — remove when Flutter <version> rollout is complete.
// Contains the immortal-tombstone replay for known-stuck devices.
// Every id these accounts erase is ALREADY deleted, so answering 200
// is a no-op for them and cannot lose live data. Verified per account
// before listing — see the two count() queries in the handoff.
const STUCK_ERASE_UIDS = new Set([
  // "uid-here",  // [user] — 155 bursts / 8wk, still firing
]);

Place it so it runs after the live-profile check that is already in the working tree, not before. That ordering is what makes the list safe even if a wrong uid is added by mistake: a listed account that turns out to have a live profile still gets a real delete rather than a false 200.

Already in the working tree

Uncommitted on fix/autosupport-payment-gates, ~21 lines in routes/user.js. Both are safe to keep independent of the list:

Note the side effect: with the live-profile check in place, the existing per-uid limit no longer blocks anything real. It only sheds no-op replays. That is the intended behaviour and it means the list can be as aggressive as you like.

What to watch

Do not

Still open