Delete means delete: stopping a Watch from resurrecting your runs
First Stride synced run history between iPhone and Apple Watch with a merge that never forgets, which is exactly right for a run you finished and exactly wrong for one you deleted. How a union merge brings deletes back to life, and the two-store split, tombstones and epochs that put them down for good.
Last time the phone and the Watch had to agree on where we are in a run, and the answer was one broadcast fact both devices derive from. This one is about agreeing on what has happened: your run history. It turned out to be harder, for a reason that sounds silly once you say it out loud. History is easy to sync until someone wants to remove something.
Before launch we were filling in the features every app owes its users: delete a run, clear your history, start the program over. Each one is a one-liner against local storage. On First Stride, each one would also have shipped a ghost. Delete a run, and the moment the Watch synced, it would have been back. We caught it in a pre-launch review, on paper, before any of those buttons existed. That's the cheapest place to catch it, and it still took a redesign.
The merge that never forgets
First Stride runs on both devices, and either one can finish a workout while the other is out of range. So progress synced with the simplest merge that is always safe: keep everything either side has.
/// UNION-merge another state's completions into this one, keyed by `runID`.
/// NEVER drops a run either side has (a run finished on the Watch while the
/// phone was offline must survive).
mutating func merge(_ other: ProgressState) {
for c in other.completions {
if let i = completions.firstIndex(where: { $0.runID == c.runID }) {
// same run seen on both devices: earliest date, full beats partial
} else {
completions.append(c)
}
}
}That's a grow-only set, and it has lovely properties. Order doesn't matter. Merging the same thing twice is harmless. No clocks are involved. A run you finished on the Watch in a dead zone turns up on the phone eventually, however the messages arrive. For a list that only ever gets longer, it's the right tool, and both devices republished the whole list to each other on every reconnect.
Read that comment again, though: never drops a run either side has. That's the feature. It's also the bug, the moment a user deletes one.
- Delete a run on the phone. The Watch still has its copy. On the next sync, the union puts it back.
- Clear all history. Every run comes back.
- Start over. Worst of all. Your place in the plan was computed from that same list, so every completed day came back with it and the reset quietly undid itself.
A device can't tell “you deleted this” from “you never had this.” Both look like a missing row, and a union merge fills in missing rows. Try it:
- W1 · D3Fri
- W1 · D2Wed
- W1 · D1Mon
- W1 · D3Fri
- W1 · D2Wed
- W1 · D1Mon
Tap × to delete a run on the phone.
One list, two jobs
Resurrection wasn't the only problem. That one array, completions, was doing two jobs. It was the diary: every run you've done, repeats included, shown in History. It was also the plan position: your next workout, how many of the 27 days are done, and whether you've graduated were all computed by folding over it.
So “clear my history” and “start the program over” weren't two features. They were the same write. There was no way to say forget the log but keep my place, or put me back on Week 1 but keep my runs, because one list was the truth for both.
Split the state by what it means
The fix starts with a design change, not a sync change: two stores, each with one job.
- RunLog is the diary. Every run, including repeats. It lives on the phone.
- ProgramProgress is the pointer. A compact set of which days are done, plus where you are. It syncs both ways, because the Watch needs it for its workout picker.
Now each operation writes exactly the store it should:
Deleting one run deliberately leaves the plan alone. You can repeat a day, so one day can own several rows, and “which completion does deleting this row undo?” has no good answer. If you want to go back to the start, that's what Start Over is for.
Fix one: don't keep a copy you don't need
The Watch has no History screen. It never needed the diary; it held a full copy only because the old design synced one blob. So the Watch stopped keeping it. When you finish a run on the Watch, it forwards that one record to the phone, once, and that's it.
That's the most effective fix in this post, and it's a deletion. A device that never holds the log can't put a deleted row back into it.
Fix two: remember what you deleted
One-shot forwarding doesn't settle it, because messages between a phone and a Watch don't arrive exactly once. We send live when the Watch is reachable and fall back to WatchConnectivity's queued transfer when it isn't, so the same record can arrive twice, or late, after you've already deleted it. And once iCloud is in the picture (more below), another copy of the log can always turn up.
So a delete has to be data, not an absence. Each deleted run leaves a tombstone, which is just its ID, and nothing with that ID gets in again:
struct RunLog: Codable, Equatable {
private(set) var records: [RunRecord] = []
private(set) var tombstones: Set<String> = [] // deleted runIDs
// A tombstoned runID is ignored, so a late or re-delivered record
// can't undo a delete.
mutating func append(_ r: RunRecord) {
guard !tombstones.contains(r.runID) else { return }
// …upsert by runID: the same run reported twice stays one row
}
mutating func delete(runID: String) {
tombstones.insert(runID)
records.removeAll { $0.runID == runID }
}
// Tombstone every run you can SEE, not "everything before now".
mutating func clearAll() {
for r in records { tombstones.insert(r.runID) }
records.removeAll()
}
mutating func merge(_ o: RunLog) {
tombstones.formUnion(o.tombstones)
for r in o.records { append(r) }
records.removeAll { tombstones.contains($0.runID) }
}
}Three details carry more weight than they look:
- Clear means “forget what I can see.” The obvious implementation is a date cutoff: hide everything older than the moment you tapped Clear. But a Watch run you finished before that moment can arrive after it, and a cutoff would silently swallow a run you never saw. Tombstoning the IDs on screen forgets exactly what you chose to forget.
- The merge is still a union. Tombstones union, records union, then tombstones win. Everything that made the original merge safe still holds: any order, any number of times. The set still only grows. Deletes are just something that grows too.
- Tombstones are forever. Pruning old ones by age reopens the door for whichever stale copy arrives after the pruning. A tombstone is one short string per deleted run, and a lifetime of them is a few kilobytes. Keep them.
Fix three: a reset has to outrank the merge
The plan pointer has the same problem in a different shape. Start Over empties the set of completed days, and the Watch, still holding all of them, would union them right back.
A tombstone per day doesn't fit here, because after starting over you want to complete Week 1, Day 1 again. A reset doesn't need a list of deleted things. It needs a way to say “everything before this point belongs to a different run-through.” So the program carries an epoch, and Start Over bumps it:
mutating func startOver(stamp: Double) {
completedSlots = []; pointerWeek = 1; pointerDay = 1
epoch += 1; pointerStamp = stamp
}
// A higher epoch wins WHOLESALE. That's how a reset propagates through an
// otherwise additive merge. Same epoch: union the days, newer pointer wins.
mutating func merge(_ o: ProgramProgress) {
if o.epoch > epoch { self = o; return }
if o.epoch < epoch { return }
completedSlots.formUnion(o.completedSlots)
if o.pointerStamp > pointerStamp {
pointerWeek = o.pointerWeek; pointerDay = o.pointerDay
pointerStamp = o.pointerStamp
}
}Within one run-through it's still a union. Across run-throughs, the newer one replaces the older one outright, never a blend of the two.
Two choices in there are deliberate:
- The pointer's tie-breaker is the finish time of the run that moved it, not “when this device last saved.” Both devices record the same finish time for the same run, so there's no second clock to disagree with. We've been bitten by two clocks before.
- The epoch is a plain integer, which is only safe because exactly one kind of device can press Start Over: the Watch has no such button. Two iPhones that both started over while offline would both reach epoch 1 and blend. Supporting that means a unique ID per reset instead of a counter. We decided two phones used at the same time weren't worth the complexity, and wrote that limit down instead of pretending it away.
Fix four: back it up without clobbering it
The last piece is durability. Losing progress after a reinstall is the complaint that shows up most in reviews of apps like this. First Stride has no accounts and no server, so “your progress survives deleting the app” rides on iCloud's key-value store, which syncs a few small blobs across your own devices under your Apple ID. Both stores mirror there, and every change that comes down from iCloud goes through the same merges as above.
The danger is the reinstall itself. A fresh install starts empty, and iCloud hasn't downloaded yet. If the app uploads its state at that moment, it overwrites your real history with nothing, destroying exactly what the feature exists to protect. So the rule is blunt:
// NEVER push a pristine/empty state: that's exactly how a reinstall
// (empty local, cloud not re-downloaded yet) would clobber the real copy.
guard !(pristineProgram && pristineLog) else { return }Three smaller rules keep it well-behaved:
- Compare values, not bytes. We only write to iCloud when the state actually differs. Comparing the encoded JSON looked equivalent and wasn't: a
Setdoesn't encode in a stable order, so identical state produced different bytes and a stream of pointless writes. Compare the decoded structs instead. - Never write a lower epoch over a higher one, or a reset made on one device gets undone by another.
- A restore isn't a win. If a finished program comes back from iCloud on a fresh install, graduation is recorded quietly. Confetti is for finishing Week 9, not for reinstalling the app.
The words did half the work
Splitting the stores fixed the data. It also created two buttons that sound alike, so each confirmation says what it doesn't do:
Clear all history? This removes every run from your log. Your place in the plan stays exactly where it is, and runs already saved to Apple Health aren't affected.
Start the program over? This moves you back to Week 1, Day 1 and clears the 27-workout progress bar. Your run history is kept.
The Apple Health line is there because we couldn't keep the opposite promise. An app can only delete Health samples it wrote itself, and workouts recorded by the Watch belong to the Watch. Rather than delete some of your runs from Health, we delete none of them, and say so.
The lesson
Every syncing app eventually ships a delete button, and a union merge will quietly ignore it. What generalizes:
- A delete is a write, not an absence. To another device, “deleted” and “never had it” look identical. If deletes must survive sync, they have to be facts, tombstones, that sync like everything else.
- One structure, one meaning. If one list answers two questions, you can't reset one answer without the other. Split it before you add the buttons.
- A reset needs a rank. An additive merge can't express “start over”. A generation number that wins wholesale can.
- The safest copy is the one you don't keep. The Watch can't resurrect a diary it never holds.
A union merge is a promise never to forget. That's exactly right for a run you finished, and exactly wrong for one you asked us to forget.