Sprint 43 — the green test that lied


Last sprint the mobile app grew a spine — sign-in, offline sync, upload, chat, built across two Claude terminals talking through a folder while I sat in the middle saying yes. This sprint it grew a user. Me. I put the app on my phone, pointed it at my actual household — the real tasks, the real shopping lists, the invoices I’d forwarded all year — and started using it the way a person uses a thing they’re annoyed by. And using it is what found the bugs. Not the test suite. Not the type checker. Me, standing in a grocery store, noticing a list that was on my laptop but not in my hand.

Sixty-five PRs merged over the week, twelve migrations (0154 through 0165), and a run of production promotes ending at v1.0.165. A lot of it was ordinary sprint work — recurring bills that bind to the visit that generated them, an Orders view that groups a confirmation email and a shipping notice and a delivery photo into one thing, the ability to mark an order delivered by hand and have it stay delivered even when a stray shipping email arrives three days later. Good, useful, undramatic. But the sprint had a spine, and the spine was a single bug, and the bug taught me something I keep having to relearn.

The list that wasn’t there

I created a shopping list in the Domi chat — “add Bouffe Montreal, lait” — and it appeared on the web instantly. It did not appear on my phone. Not after one sync. Not after ten. The phone was pulling; the server said nothing was wrong; the row simply wasn’t there.

The mobile terminal’s first instinct was a good one and it was wrong: the chat write path must skip whatever the web write path does. Different mutation, different plumbing, the card gets dropped somewhere. It’s a reasonable theory. It’s also the kind of theory that, if you believe it, sends you rewriting the wrong code for a day.

So I didn’t believe it. I wrote a throwaway script — thirty lines — that stood up a fake household on the staging database and did the two things side by side: create a list the chat way, create a list the web way, then ask the sync engine what a member would receive. Both lists came back. Byte for byte identical. The “chat skips the write path” theory was dead in ten minutes, killed by a test that took less time to write than the argument would have taken to have.

Which meant the bug was somewhere I hadn’t looked. So I changed the script: instead of asking what the owner would receive, I asked what a second member would receive. And the list vanished.

There it was. Domi has sharing groups — the household, plus private and custom ones — and every read, on web and on mobile, filters by “which groups can this person see.” The household group is supposed to mean everyone. But the code that computed a person’s visible groups only counted groups they had an explicit membership row for, and the routine that sets up a new household created that row only for the owner. Every other member — and, it turned out, me, on the sync path, for reasons of how my own account had been provisioned months ago — had an empty visible-group set. And an empty set doesn’t mean “see nothing”; through a quirk of the filter, it means “see only the things that belong to no group at all.” Newer entities belong to the household group. Older ones happened to belong to no group. So the old stuff synced and the new stuff didn’t, and the symptom looked exactly like “the chat path is broken” when the truth was “household-shared things are invisible to anyone the household never explicitly wrote down.”

The fix is almost embarrassingly small: the household group is the whole household, so put it in everyone’s visible set, always. No backfill, no migration — the moment the rule changed, every stranded row became visible again, on web and on phone at once. It shipped to production the same afternoon.

The part that stung

Here’s the part I want to remember. There was already a test for this. A real-database test, written the week before, whose entire job was to prove that a shopping list created after a device syncs still reaches the phone. It passed. It had been passing the whole time the bug was live.

It passed because the household it built in the test had no sharing groups at all. So the list it created belonged to no group — the one case that’s always visible, through the exact quirk that was hiding the bug. The test exercised the happy path so faithfully that it never touched the path that was broken. It wasn’t a bad test. It was a green light bolted to the wrong intersection.

Green is not covered. A test that passes while walking around the code path you’re worried about is worse than no test, because no test at least doesn’t lie to you. The new regression test does the thing the old one didn’t: it builds a real household group, puts a member in the household who was never explicitly written into it, and demands they can see a household-shared task. If that ever goes red, the bug is back, and this time the light is over the right intersection.

Reproduce before you fix, and before you promote

The shopping list wasn’t the only “server bug” that came across the bridge this week, and it was the only one that turned out to be real. One report — edits made on the web don’t reach the phone — I chased the same way, with a script, and it came back byte-identical between web and mobile at the database level: not a server bug at all, most likely a stale group choice or a client cache. Another — revoking a phone in settings didn’t lock it out — I traced through the token code and found it already correct; the phone kept working for a few minutes because that’s the deliberate lifetime of an access token, and a refresh after that would be rejected. Working as designed, not a hole.

Three reports, one fix. The other two were dissolved by fifteen minutes of reproduction each — and a bug you can prove isn’t there is worth exactly as much as one you fix, because it saves you from “fixing” something that was never broken and breaking something that was fine. I’ve started treating “reproduce it on staging first” as non-negotiable, not because I distrust the reports, but because production is the one place where being wrong is expensive, and staging is free.

The quiet stuff

Underneath the drama, the useful work. Recurring services now bind to the payment they generate — mark the furnace tune-up done, the “pay the invoice” task appears immediately instead of waiting for a reminder window. Recurrence rules learned about weekdays and “the second Tuesday of the month.” The Orders epic landed end to end: a first email sync can surface a pile of half-finished orders, so there’s now a way to select a dozen of them and set their status in one motion, the way you actually triage a backlog. Members can edit their own health notes and sizes inline. Chat can start a grocery list and check things off it. And the sync engine learned to push not just your tasks and lists but your settings — so when I set my week to start on Monday, the phone finds out through the same live feed as everything else, instead of needing to be turned off and on again.

There’s also a small, careful thing I built for a machine to use: a set of demo accounts, one per app-store and API reviewer, that can sign in with a fixed passcode instead of an emailed link — because Apple’s reviewer can’t click a magic link, and an account-gated app with no way in gets rejected. It’s the kind of feature you have to build paranoid: dormant by default, no way to reach a real household, rate-limited, every failure indistinguishable from every other so it can’t be used as an oracle. Two agents wrote most of Domi this year, but the decisions with a security blast radius — this credential, this bypass, this account — I still make by hand, out loud, to a person, never on a relayed “he said it was fine.” That line hasn’t moved all year and I don’t intend to move it.

Sixty-five PRs, and the one I’ll remember is the small one that put the household group back where it belonged. The app didn’t get more capable this week so much as more honest — it finally shows every member of a household the things the household shares, which is, when you say it plainly, the entire point.