← All posts

The rollover that raised the dead

On June 11 a user watched tasks they had completed come back unchecked. A postmortem on a midnight rollover that raced our sync pull and won.

On the morning of June 11, 2026, a user sent us a report we read twice: tasks they had completed the day before were sitting in today's column, unchecked, as if the work had never happened. For an app whose whole promise is a trustworthy week, this is close to the worst bug there is. Losing a task is bad. Un-doing a done task is worse, because it tells you the app might be quietly rewriting your own past.

Some background. weekkii rolls unfinished tasks forward: around midnight, anything incomplete from yesterday moves into today. We like this feature because it matches how weeks actually go. But rollover is an automatic edit. The app changes your data without you touching it. Keep that in mind, because it is the whole story.

What actually happened

The user had weekkii on more than one device. The evening before, they finished their day on one of them and closed the tab. We debounce writes before flushing them, and the tab closed before the debounce fired. That device went to sleep with a stale local database: it still showed yesterday's tasks as incomplete, even though the completed versions were on the server.

Next morning, the app started on that device. weekkii is local-first: every read hits local SQLite, and a sync pull merges in the server's rows shortly after startup. The rollover, though, ran before the first pull had finished. It looked at the stale database, saw a pile of "incomplete" tasks in yesterday, and did exactly what it was built to do: it moved them into today. And because that move is a local edit that has not synced yet, those rows were marked dirty.

Here is where a good rule turned bad. Our merge rule is dirty-wins: if you edited a task locally and have not synced, your local copy beats whatever the server sends. This is the right call for humans. If you rename a task on a train, a stale server copy should never overwrite your rename when you come back online. But the rollover's move counted as a local edit too. So when the pull finally arrived, carrying the completed versions of those tasks, the stale rolled copies won. The one sync that would have corrected everything was rejected by our own rule. Then the push ran, and the resurrection spread to every device the user owned.

No single step was wrong. Debouncing writes is normal. Running rollover at startup is reasonable. Dirty-wins is correct. Chained together, they dug up the user's finished work and dropped it back on their desk.

The fix

We shipped the fix within the week, in two parts. First, rollover now waits for the first sync pull to settle before it is allowed to touch anything. If the pull cannot complete (a fully offline device, a dead network), a bounded grace period of about 8 seconds lets rollover proceed anyway, because a plane-mode device still deserves a working rollover. Second, we stopped trusting the debounce at the exact moment it is least trustworthy: we now flush pending writes when the tab hides, instead of hoping the flush wins a race against the close button.

The lesson we keep is this: every automatic mutation is a user edit as far as the merge is concerned. The sync layer cannot tell a human's checkbox from a robot's midnight cleanup; both arrive as dirty rows that beat the server. So automation must never run on state it has not verified is fresh. Dirty-wins is the right rule for humans and a dangerous rule for robots.

One honest residual: a device that stays offline past the grace window can still roll a task that another device completed. We could pretend the fix is total. It is not, and we documented the limitation instead. The gap between "fixed" and "fixed except for this narrow case we wrote down" is exactly where trust lives.

Tasks you finish should stay finished. It took one race condition, two devices, and a merge rule doing precisely its job to remind us how much engineering hides inside that sentence.