← AstroMatrix Reports — all pages 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: "", isDeleted: true }).count() db.users.find({ 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 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: The live-profile check. Before the rate limiter synthesizes a 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. A corrected removal condition. The comment said to retire the guard once the _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. What to watch ratioCalls ÷ distinct profileIds per uid per day. The recovery signal. Should fall toward 1.0 as clients update. live-hitrate 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. replaysBLOCKED replay count per uid. This is your rollout progress bar; when it reaches zero for a listed account, remove that uid. 404s404 rate on /erase. Should stay non-zero and healthy — that path is how clients self-heal. A sudden drop to zero suggests something upstream is swallowing it. Do not Return 200 from any new short-circuit path. Middleware, a cache, an early return — anything that answers before the live-profile check reintroduces silent data loss. Throttle live deletes with 429 or 503. Three of them permanently abandon the delete on a fixed client. Branch on client version. There is no version header. Any code that appears to detect client age is guessing. Strip the body from a 404. Both client generations read "not found" from it to decide the delete is done. Losing that wording turns the one self-healing case into a retry. Retire the guard on the _saveToLocal date. Wrong bug. Wait for the tombstone fix to be live and for replay volume to actually fall. Still open The field cleanup is deliberately held. A one-shot drain for tombstones already stuck on devices is not written yet, and must not ship until the client fix is confirmed live — a one-shot cleanup cannot drain a live producer; it fires, sets its flag, and the producer refills the queue. It will also need a new flag key, since the existing one is already burned on every install. Stuck devices stay stuck until then. Containment is what the list is for. Their replays hit already-deleted profiles, so the guard sheds them. The client delete path has no test coverage. 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. Client fixes are uncommitted on new-ui. Server changes uncommitted on fix/autosupport-payment-gates. Nothing pushed.