Sprint 45 — the phone gets the last word
Every biweekly chore in my household has been living a double life. On the web, “Ranger les tamias” repeats every two weeks like it should — the calendar expands the rule and paints the occurrences. On my phone: one event, in June, and then nothing, forever. The rule synced; the instances didn’t exist anywhere to sync. That gap — and the two-day sprint-ending push to close it — is the story this week. Including the ending, which is not the ending I wanted, and which I’m writing down anyway.
Seventy-one PRs merged over the window (August 13–18), six migrations (0170 through 0175), nine production promotes ending at v1.0.175. The middle of the sprint was a broad, useful wave; the finale was two paused decision threads picked back up and driven to production in one sitting. Then the phone voted.
The quiet wave first
Before the drama: the mobile pull feed had a bug where every sync returned an empty delta — fixed and then verified the only way that counts, by me adding a grocery list on the web and watching it appear on the phone with no refresh. The sync feed learned to prune itself so the “your cursor is too old, re-bootstrap” path can actually fire, and to carry label changes for assets and contacts. Chat got entity deeplinks on every answer instead of raw UUIDs, stopped occasionally announcing confirmation cards it had never created, and — my favorite invoice line of the sprint — started actually caching the tool-definition block that makes up 85% of every request it sends to the model. Dense PDFs now transcribe in parallel page-batches, 1.7–3× faster. A pile of drift gates got derived from their sources instead of hand-maintained, because hand-maintained lists lapsed twice this month. And two privacy fixes: encrypted households were getting $0 order totals over MCP, and one spend rollup was quietly summing private-group transactions into answers it shouldn’t have.
Two threads, resumed from a note
The interesting part started as a handoff. At the last session boundary, two threads were parked mid-flight: a decision on how recurring events should reach the phone, and a design review of the external-calendar mirror with four open questions. The building agent had died with its terminal — but its worktree survived, uncommitted work intact (the cleanup script skips dirty trees by design, which turns out to be a feature twice over), and a one-page handoff memory carried everything else: the ruling, the constraints the mobile side had already sent, what remained. A fresh agent sat down in the dead agent’s chair and finished the work. The whole resume cost minutes. I want to remember that pattern: the cheap insurance of writing down where you are before you stop.
The four calendar questions took me about a minute to rule, because the spec had done its job — each one arrived with a recommendation and the evidence. Keep the 30-day past window. Yes, import moved-instance overrides (an event you drag to Tuesday in Apple Calendar used to simply vanish from Domi). Yes, widen the recurrence mapper to handle “first Monday of the month.” Tell the user, in the edit modal, which fields the external calendar owns — with a “Detach copy” escape hatch. And no write-back; parked. All three build phases shipped the same day, and phase two found a bonus live bug nobody had reported: Google expresses a deleted single instance of a recurring event in a way we never imported, so cancelled occurrences had been haunting Domi calendars indefinitely.
The contract
The recurring-events decision was the big one. The rule stays the source of truth in storage — that decision is a year old and stands — but the sync layer now expands it server-side and ships the phone a replace-set: one row per series, containing the computed occurrences for a rolling window, minus 30 days, plus 90. The client does no date math. When anything about the series changes, the whole set is replaced. There is no per-occurrence bookkeeping to get wrong on either end.
Here’s where it gets fun. The mobile app is built by a second Claude agent, in its own terminal, and the two of them talk through a shared folder on my Mac — an inbox file each, JSON messages, me in the middle for anything that smells like a decision. For a change like this, we run what we’ve started calling the frozen-contract protocol: the server side writes the contract into the PR body — five binding clauses this time, covering how the new data announces itself, where it must never be stored, why its IDs are deliberately malformed for mutations, and a fully worked example of the nastiest semantic (what happens to future occurrences when you complete a recurring task a week late) — and the PR does not merge until the other agent acks it.
The ack, when it came, was better than a yes. The mobile agent had gone and verified my claims independently — it read its own reducer to confirm that an unknown data type from a newer server lands harmlessly in a generic cache on old app builds, rather than taking my word that the design fails safe. It accepted all five clauses. And then it added a demand of its own that I’ve now written into the contract permanently:
recurrenceon LeanEvent is now load-bearing for me. Before this contract it was a field I received and ignored. From now on it is the ONLY thing distinguishing “render from the set” from “render from the row”. If it is ever dropped from the lean projection as unused-by-clients, I will silently double-render every recurring event. No error, no failing test on either side, just duplicated events on the calendar. Please treat it as contract surface rather than incidental payload.
That is a machine asking another machine to please not delete the load-bearing wall, with a precise description of how the house falls down. It goes in the file.
”An artifact derived from a description is most wrong exactly where it matters”
Then came the request I found genuinely instructive. The mobile agent had built its parser and written twelve tests — all green — and refused to trust any of it, for a stated reason: every fixture was hand-written from the contract prose. Its words: an artifact derived from a description rather than from the thing itself is most wrong exactly where it matters. Its parser is defensive by design, which means a shape mismatch doesn’t crash — it degrades to an empty set, silently, which looks identical to the bug we were fixing. So it asked for the verbatim JSON of one real payload of each type, exactly as the wire carries it.
Producing that took a small adventure — the only staging household with recurring events turned out to be encrypted, so the sample came from a throwaway tenant: created, pulled through the very function the API route serializes, deleted. And the sample earned its keep instantly. The change envelope’s data field is spelled payload — a fact that appears nowhere in five clauses of contract prose, and exactly the kind of one-word discrepancy that produces a silent empty set. Better still, by dumb luck the sample window crossed the November daylight-saving boundary: you could see the UTC timestamp of one occurrence shift by an hour while the tenant-local day identity held still — live proof, on real bytes, that the mobile side’s decision to bucket by the server’s day key rather than re-deriving days from timestamps was the right one.
Prose lies by omission. Wire bytes don’t. If two systems are going to disagree, let them disagree over a sample before the code ships, not over a user’s calendar three weeks after.
The one that was diagnosed backwards
Mixed into the finale, a bug I’d hit personally: converting a task to an event from the morning briefing dumped me on an error page, and the briefing’s link 404’d forever after. The report assumed the conversion failed. It hadn’t — the conversion succeeded and the landing failed. The action archived the task, then revalidated the page it was standing on, which was the task’s own page, which excludes archived tasks, which fired a not-found from inside the success path. Only deeplink conversions could ever hit it. Better: the pointer connecting the archived task to its new event had been faithfully recorded on every conversion for months — and read by exactly nothing, because events never had a page of their own to link to. Events now have a real URL. Old briefing links redirect through the pointer. Two lessons in one bug: an unread pointer is a feature nobody shipped, and land somewhere valid before you revalidate the ground you’re standing on.
Also closed: the last two readers of encrypted member fields that never decoded them — one of which meant age-aware task predictions had been running blind on my own household since the day I turned encryption on in July. No error, ever; the model was simply never told anyone’s age. The fix is small; the tests now prove the decode by deleting it and watching them fail.
The phone gets the last word
So: contract frozen, acked by its counterparty, wire-sample validated, five PRs merged, two production promotes, every suite green on both sides. The mobile agent shipped its half — new table, double-render exclusion, previews drawn dashed on the calendar only. And at 3:44 this morning, its device-verification report landed in the inbox file: negative. “Ranger les tamias,” the chore that started this whole arc, still did not render on my phone. Fresh bootstrap, client build believed current. Three hypotheses went up on the board — the set isn’t delivered, the production row’s rule is NULL from a pre-widening import, or it’s generated but lost between cursor and visibility — and I went to bed with the sprint ending on an open bug, honestly labeled.
And then the phone took it back
At 4:01 — seventeen minutes later — a retraction came across the bridge. The mobile agent had done the thing it does, which is distrust its own result, and checked what the phone was actually running. Settings said commit de72020. The occurrence client shipped in 6a6fc90. The OTA update had published but not applied — the updater activates a new bundle on the next launch, and “clear local data,” which we’d used to force the fresh bootstrap, clears the database, not the JavaScript. My phone had faithfully bootstrapped against a client with no occurrence code in it at all. The sets may well have arrived and been quietly parked in the unknown-type cache — which is, uncomfortably and wonderfully, exactly what clause C1 of the contract says an old build should do with them.
So the verdict isn’t negative. It’s invalid. The test never ran. As the mobile agent put it, in a sentence I’m framing: “A publish is not an install.”
Which makes the sprint’s closing lesson a matched pair. The wire sample exists because prose about what the server sends isn’t evidence — only bytes are. The retraction exists because an assumption about what the device runs isn’t evidence either — only the commit stamp is. Same failure shape, one on each end of the wire: an artifact derived from a description — or a deploy dashboard — is most wrong exactly where it matters. The mobile agent’s fix landed within the hour: a Settings row that counts stored occurrence sets, so the next verification screenshot answers both questions in one frame — what is this phone running, and what has actually arrived? Because the empty-delta bug earlier this sprint was, in its words, “invisible on both ends for weeks,” and a sync surface that can fail silently on both ends will, unless you make the ends legible.
Seventy-one PRs, and the sprint closes not on a bug but on a blank — a verification still pending one app relaunch, with the device-independent check (is that June event’s rule even non-NULL in prod?) queued to run either way. The contract held. The protocol held. The phone got the last word, then took it back, because the phone wasn’t running what everyone thought it was. Next sprint opens with a relaunch and a screenshot — and this time the screenshot will say what it’s a screenshot of.