Backend handoff · deletion safety
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.
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.
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.
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.
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.
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.
Uncommitted on fix/autosupport-payment-gates, ~21 lines in routes/user.js. Both are safe to keep independent of the list:
200, it confirms the profile is already deleted; if it is still alive the real delete goes through. This is the floor that makes every other guard safe to tighten._saveToLocal fix shipped. That was a different, earlier bug — retiring on that date would have pulled the guard while the real cause was still live.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.
rate limit hit but profile is LIVE should be rare. Frequent means the per-uid limit is too tight for real users — and before the fix those were silent false deletes.BLOCKED replay count per uid. This is your rollout progress bar; when it reaches zero for a listed account, remove that uid."not found" from it to decide the delete is done. Losing that wording turns the one self-healing case into a retry._saveToLocal date. Wrong bug. Wait for the tombstone fix to be live and for replay volume to actually fall.RemoteUserBackend is a static final with no injection seam, so the success-path purge can't be asserted from a unit test. That gap is how this shipped broken; the storage-level contract is covered, the control flow is not.