Engineering notes
A diary must never lose a word: surviving CloudKit
August 12, 2026 · Kaloyan Lachezarov
A diary app has exactly one non-negotiable: nothing the user wrote may ever vanish. Not in a sync conflict, not in a migration, not because someone did something strange in iCloud settings. Everything else in Deforget is allowed to degrade politely. This is not. The principle behind every fix in this post: when in doubt, a diary prefers resurrecting a deleted paragraph to losing a written one.
One constraint shapes the story. Deforget syncs through the user's private CloudKit database - I never see a word, and I could not log into anyone's iCloud to help if they begged me to. So every recovery path here is self-service, on the device. CloudKit ate my diary twice during development, then jammed it a third way for good measure. Each incident bought a layer of the architecture.
The invisible race
The first data loss did not involve two people typing at once. It happened at launch, with one device sitting still.
CloudKit does not hand you your data in one piece. Apple's documentation describes sync arriving "on a natural rhythm" - typically within a minute, on the system's schedule, not yours - which means a fresh launch imports records piecemeal, over seconds. My reconciler - the code that merges duplicate days and cleans up after sync - ran immediately at startup. So it ran against a half-imported store, concluded the missing records were deletions, "merged" accordingly, and synced its conclusions back up. From the outside it looked exactly like "the older device overrode the newer one," and I hunted for a conflict bug that did not exist.
The fix is almost embarrassing: the reconciler now waits behind a twenty-second quiet window, and every incoming change pushes the window back. The lesson is worth more: at launch, your local store is not the truth. It is a partial download that looks like the truth.
Last-writer-wins eats a paragraph
The second loss was the classic. A day in Deforget is one record with a text field. Write on two devices in the same window of time and the conflict resolves last-writer-wins: whichever device saves second silently erases the other's paragraphs. No error, no callback, no trace. That is not a bug - Apple states it plainly: conflict resolution "is implemented automatically... using a last writer wins merge policy," and the recommended mitigation is finer-grained records, so conflicts get smaller. Good advice, and a diary cannot take it - a day of writing is irreducibly one field of prose. Last-writer-wins is a fine policy for a preferences file. For prose, it is a data-loss policy with good manners.
The defense has two parts. First, every device keeps a shadow: a device-local, deliberately never-synced record of the text this device last wrote or accepted. Sync can clobber the record; nothing can clobber the shadow.
Second, incoming text is never taken at face value. The device runs a paragraph union: the incoming text keeps its order and wins all revisions, and any paragraph the shadow holds that is missing from it gets re-appended. The real function is small enough to show:
merge(known, incoming, tombstones):
recovered = []
for paragraph in paragraphs(known):
if paragraph in incoming: continue // survived
if hash(paragraph) in tombstones: continue // deliberately deleted
if incoming has a revision of it: continue // edited, not lost
recovered.append(paragraph)
return incoming + recoveredThe two guards make the union livable. An edited paragraph must not be resurrected next to its own rewrite, so containment or strong word overlap marks a revision, and revisions stay dead. And a deliberate deletion must stay deleted everywhere, so deletions are recorded as tombstones - hashes of the removed paragraph, synced with the entry. A deletion is not a clobber; it has to win.
Tombstones hide the subtlest bug in the system - a pre-launch audit caught it before any user did. Deletions are computed by diffing the editor's text against a baseline, and the baseline must be what the editor last displayed, never the live record: when a sync import lands mid-typing, it rewrites the record with a paragraph the editor never showed, the diff calls that a user deletion, and the false tombstone kills the paragraph on every device at once. So deletions diff only against what was actually on screen, and anything the record gained underneath folds in through the union.
And if the incoming text is empty, the union recovers everything the shadow holds: a wiped record restores itself from the last device that knew better.
The accepted cost: a paragraph deleted on one device can come back if another device still held it. The union's failure mode is an extra paragraph you delete twice; last-writer-wins' is a paragraph you never see again. A diary prefers it this way.
The wedge
The third incident was not a conflict at all, and it was the scariest, because nothing failed loudly.
I deleted the app's iCloud data from the Settings app - exactly the kind of thing a curious beta tester does. CloudKit has a documented name for that: userDeletedZone, "an error that occurs when the user deletes a record zone using the Settings app." The documented meaning is intent - the user chose to stop syncing - and Core Data's sync machinery honors it: it stops, and it never re-initializes on its own. But nothing tells the user - no alert, no banner. The error surfaces in exactly one place an app can reach, the sync container's event stream, and only if you are listening. I was not listening yet. Every device kept working perfectly, locally, indefinitely, while silently syncing nothing. I found the truth in the system log, on a tethered Mac: "Never successfully initialized and cannot execute request." Users cannot read Console.app, and I cannot read it for them - there is no server, no dashboard, no way for me to even know they are wedged.
So the app has to know. A health monitor now listens where the errors actually surface: the container's event stream, which reports every setup, import, and export attempt with its error. Persistent errors with zero successes means wedged, and the app says so, in words: "Your diary is safe on this device. Tap to repair." The repair is self-service and paranoid in the right order: snapshot every record to a local backup first, and refuse to go further if that backup cannot be written; only then delete the broken zone, rebuild the local store, restore, and let fresh sync metadata re-export everything. The interface owns the whole path back - because nobody else can.
The three truths
Then the repair caused the fourth incident, because my advice was wrong.
The repair screen originally said: now repair your other devices too. Each repaired device re-uploaded its restored diary as brand-new records - duplicate days in the cloud, winners ping-ponging between copies, entries seeming to vanish because they sat in the duplicate the screen was not showing. The system converged eventually - the union does its job - but I had shipped instructions that manufactured the very conflict the architecture existed to absorb.
The fix was doctrine, not code. There are exactly three truthful answers to "whose copy is right," and the interface now offers them by name:
- Repair - this device's copy is the truth. Run it on exactly one device.
- Adopt - the cloud's copy is the truth. Run it on every other device.
- Delete everything - no copy is the truth. Nukes both, and says plainly that other devices keep their local copies until you delete them there.
Recovery tools are only as safe as the sentence that tells you when to use them.
Count the words
The test that matters is not "does sync work." It is: put real devices in airplane mode, write on all of them, delete something on one, come back online, wait, and count the words. The first time I ran that protocol for real - an iPad on iPadOS 27 beta 2, an iPhone on iOS 26 - both conflict scenarios converged on the union with nothing lost. The one finding was about latency, not loss: stale text until relaunch, fixed with a reload. That is what a healthy sync layer's bugs look like - complaints about freshness, never about absence.
None of this is clever. It is shadows, unions, hashes, and three sentences of doctrine, stacked so that every failure lands on "an extra copy survived" instead of "a word disappeared." A diary that guards its words this hard can afford to be ambitious about everything else - including reading them with a model that never leaves the device.