I deleted Wimemo from my phone, reinstalled it, signed back in, and watched my plans disappear.
That sentence sounds like a routine founder anecdote until you see the timeline.
The visible failure was simple: after reinstalling, the app came back without my avatar and without the plans I expected to restore from the cloud.
The invisible failure was worse.
Four minutes after login, the backup copy on the server had been tombstoned by my own sync engine.
The restore path was not merely failing to help. It was arriving too late to stop destruction.
This incident forced me to tighten one of the most important distinctions in sync-heavy products:
An empty client is not the same thing as an authoritative empty account.
That sounds obvious when written out. It was not obvious enough in the protocol I had shipped.
Wimemo is an iPhone app for private travel memory. Photos stay on device by default. Cross-device restore is intentionally limited to metadata such as trips, plans, profile state, and shared-structure context, not raw photo media. In current developer language, this sits somewhere between offline-first and local-first architecture.
Google's current Android architecture guidance still describes an offline-first app as one that remains usable without reliable connectivity and presents local data immediately. It also says the local data source should be the canonical source of truth for what higher layers read. Ink & Switch's local-first work pushes the same family of ideas further: collaboration plus ownership, across devices, without giving up control of your own data.
Those ideas are directionally right. My bug lived in the seam between them.
On a brand-new install, the local device is definitely local.
It is not yet authoritative.
That distinction matters because reinstall is a transitional state. The app has local storage, but it has not recovered what used to be there. If the protocol mistakes that temporary emptiness for a user's deliberate deletion, the restore system becomes a self-destruct system.
That is exactly what happened.
The Broken Assumption Was Hidden Inside Sync
The server-side sync-batch function treated the client's full plan snapshot as authoritative. If the client pushed plans: [], the server interpreted that as "the user now has no plans" and tombstoned every server-side row missing from the snapshot.
Under normal steady-state use, that behavior had a logic to it. The client had the freshest local picture. If a plan vanished locally, the server should eventually converge toward that absence.
The reinstall path broke the assumption.
On a fresh install, the first metadata sync ran before cloud restore had a chance to rehydrate anything. The client therefore told the truth about the wrong moment: right now, on this empty install, there are no plans.
The server heard a different sentence: this account should now have no plans anywhere.
That is how a backup gets erased by the product that is supposed to restore it.
The other half of the bug was reachability.
The automatic restore path only ran when local trips were empty. In real life, onboarding photo scan created local trips before Atlas even appeared, so the restore condition almost never survived long enough to be true. There was also already a manual restore sheet in the app, but it was not mounted anywhere a real user could reach it.
So the system had two failures at once:
- the automatic restore trigger almost never became reachable in the real flow
- the pre-restore empty snapshot was still destructive on the server
That is an ugly combination because each half hides the other.
If restore never fires, you blame the UI or the onboarding sequence.
If sync is destructive, you blame the backend.
In reality, the bug was a contract bug between product state and protocol state.
The Audit Changed the Severity
I did not want to publish a dramatic story around a one-off founder mistake, so I audited both regions before deciding what the real lesson was.
The server-side event tables covered enough history to look for the fingerprint: a plan tombstone close to onboarding, where the plans had existed before the install window.
Across the CN and global environments, the audit found exactly two affected founder-controlled accounts and nine affected plans. No third-party user data matched the incident pattern. All nine plans were recoverable because the rows had been soft-deleted rather than physically destroyed, and I restored them by clearing tombstones directly in the database.
That changed the story in two ways.
First, the blast radius was narrower than the worst-case fear.
Second, the architecture had accidentally preserved the evidence needed to reverse the damage.
That matters. Reversible history is not just a compliance or analytics nice-to-have. It is what turns a terrifying data incident into a recoverable engineering incident.
If a sync protocol can issue destructive writes before restore state is settled, soft deletes and timeline evidence are not optional safeguards. They are part of the product contract.
The Fix Started With a Safety Brake
The first production fix was deliberately blunt.
On the backend, sync-batch stopped sweeping server plans when the client snapshot was empty. That immediately prevented a fresh install from tombstoning the backup before restore could run.
The tradeoff was real and acceptable: if a user truly deleted their last local plan, the deletion would no longer propagate on that exact empty push. The stale server row would clear on a later non-empty sync instead.
That is a good example of the priority order I want in recovery-sensitive systems:
- stop destructive false positives first
- restore legitimate cleanup semantics second
Too many teams try to preserve elegant steady-state behavior while incident pressure is still active. In this case, safety had to beat symmetry.
Restore Needed a Protocol State, Not a Boolean
The deeper iOS change was to remove the fake simplicity in the old restore model.
Previously, the app stored an attempted-once boolean. If restore had "already run," it would not run again. That sounds reasonable until you ask what counts as a meaningful attempt.
A successful decode, a network timeout, a 500 from the server, and a route the user never actually reached are not the same state.
So I replaced the boolean with a per-account tri-state: pending, completed, and skipped.
pending means this install has never successfully settled restore for that account. completed means the snapshot was fetched and applied successfully. skipped means the app concluded there was nothing to restore and may proceed as an intentionally empty account.
That changed more than retries. It gave the sync protocol a real attestation surface.
Now the client includes restore_state in every metadata sync payload. The backend only allows an empty snapshot to sweep server-side plans when the client attests completed or skipped. Missing state, legacy clients, or pending all keep the empty-snapshot safety guard in place.
Restore is not a button, sheet, or onboarding branch. Restore is part of the synchronization contract.
If your protocol supports destructive reconciliation, it needs to know whether the client has actually finished recovery.
Reachability Is a Product Property
The restore trigger also had to become reachable in normal life, not only in lab conditions.
The auto-restore guard that required zero local trips was removed because it was the root cause of all three real reinstall failures seen in the timeline. The restore apply path was already idempotent enough to safely re-run: trips patch or ghost by stable country-plus-date identity, and plans dedupe by stable ID. That meant the "safety" guard was never really protecting data. It was only preventing recovery.
I also mounted the manual restore entry in the signed-in settings path so a user can explicitly ask for cloud recovery without discovering a hidden sheet through source code archaeology. And while touching the pipeline, I backfilled plan checklist decoding because the server had been returning it already; the client had simply never been applying it.
A recovery flow you cannot reach from the real user path is not a recovery flow. It is an implementation detail.
I have become much less patient with "the UI exists somewhere" as a completion argument. If a user in the failure state cannot actually find it, it does not exist operationally.
Account Mismatch Can Look Exactly Like Data Loss
One more edge case surfaced during the incident: OTP sign-in can silently create a fresh account for an unrecognized phone or email address.
That means a user who normally signs in with Apple or WeChat can accidentally type a different identifier, land in a perfectly valid but empty new account, and experience it as catastrophic loss.
The system is technically working. The human reading is still "my data is gone."
So I added an empty-account warning after OTP verification. If the account has no local trips, no local plans, and no server-side restore snapshot, the app now pauses and tells the user this account has no data yet, then offers a way back to another sign-in method. A failed server probe never blocks login; it only suppresses the warning.
That fix is smaller than the handshake change, but it matters because incident prevention is often about reducing false interpretations, not just eliminating protocol bugs.
The Reusable Model
The lesson I would generalize is not "add a tri-state enum."
It is this:
Any system that mixes local authority, background sync, and cross-device restore needs a distinct state for "not restored yet."
Without that state, an empty install, a network failure, a wrong-account login, and a legitimate user deletion can all collapse into the same protocol shape: empty.
That is not a data model. That is ambiguity with production consequences.
The model I use now is:
- Separate steady-state emptiness from transitional emptiness.
- Make destructive reconciliation contingent on explicit recovery state.
- Prefer reversible deletes until the protocol has earned stronger guarantees.
- Verify reachability of recovery UI in the actual post-failure path.
- Warn when identity ambiguity can masquerade as data loss.
What Remains True
There are still limitations. Legacy clients that never send restore_state keep the empty-snapshot guard permanently, which favors safety over perfect cleanup symmetry. Cross-device restore in Wimemo still covers metadata, not raw photo media. And any sync system with mixed online and local authority still depends on human judgment about what should be merged, patched, skipped, or surfaced to the user.
That is fine. Good recovery design is not about pretending ambiguity is gone. It is about making the dangerous states explicit.
I build Wimemo as a one-person company, which means I do not get to hide behind handoffs when a restore promise is leaky. If the product says your memories stay yours, then reinstall has to be treated as a first-class systems problem, not just a support edge case.
The thesis I am carrying forward is simple:
In a local-first product, restore must be part of the protocol that grants authority. Until recovery is settled, empty is only a temporary observation, not a deletion.
I write these from building Wimemo, a private travel memory app. If you want the product context behind the engineering decisions, you can read more about Wimemo here.