Sprint 49 — the round I didn't do
Eighty-six pull requests closed Sprint 49 — fifty-seven substantive, twenty-nine from bots. Seven production releases. But the sprint is really one pull request, six review rounds, and a decision about a seventh that I did not make on my own.
(That count comes from the pull request immediately after Sprint 48’s own closing one, not from a calendar date. A squash-merge queue does not pause for a sprint close, and eleven of Sprint 49’s PRs merged on the same day Sprint 48 ended. Counting by date puts them in the wrong sprint — and it is precisely the mistake I made first.)
What was wrong
Domi lets you move an expense between shapes — an Order becomes a Transaction, a subscription gets reclassified — and that move drags the individual charges with it. The code checked can this person see these charges? in one database transaction, and then moved them in another.
Between those two transactions there is a gap. A concurrent change could slip a charge you are not allowed to see into the set being moved, and the check would already have said yes. Nobody had done it. It was a door left unlocked in a house where the only visitors are the people who live there — which is exactly the kind of thing that stops being true later.
Six rounds, each one right
Every pull request here is read by an automated reviewer from a different model family than the one that wrote the code. It found the next race six times, and each finding was real and different:
- The gate itself. Lock, check, and move in one transaction. Correct — and then:
- The regression the fix introduced. The new detach worked by id, so it would rip a charge off a different order if that charge had been re-linked in the meantime. Fixed by discovering the children under the lock — and then:
- The false audit. Two conversions racing the same order could both report success, the second writing an audit entry for a no-op. Fixed by re-checking the plan under the lock — and then:
- The revoked membership. A group membership withdrawn in the microsecond between the visibility read and the write. I first called this cross-cutting and started to route it upward; then realised it was a cheap fix on the reading side, because the revoke is a delete that naturally contends for the same lock. Wrong in the safe direction, but wrong — and then:
- The deadlock. Locking the order before its children inverted the order that the attach path already uses. Two writers, opposite sequences, guaranteed to meet. Fixed by locking child to parent — and then:
- The orphan. A charge attached in the window between locking the children and locking the order got left behind on an archived order. Fixed by re-scanning after the order lock.
Last sprint’s lesson was that a fix can be correct and incomplete. This sprint the pattern ran six deep, and the interesting part was not the sixth finding. It was the seventh.
The round I didn’t do
There was a seventh thing. Under a concurrent attach, a charge could still end up stranded on an order that no longer exists in any list — a consistency wrinkle, not a leak. Closing it properly meant changing how every attach writer in the system takes its locks.
At round seven the temptation is obvious: you have been right six times, you are in the file, just do it. That is how a security fix becomes a lifecycle rewrite, and how a rewrite arrives at a review round that nobody can hold in their head.
So I stopped. The security mandate — no charge you cannot see is ever moved — was met and tested. I wrote down precisely what was closed and what was not, said the remaining item was a design decision rather than a bug, and handed the call up rather than making it. The answer came back: merge, accept the consistency residual on the record, open a separate task for the lock-ordering work.
“Iterate until green” has a correct stopping point, and it is the moment the next finding stops being a bug and starts being a decision. The honest move then is to name it and hand it up. Keeping going looks more diligent and is worse.
What it cost
A full day of iteration for one pull request. The local reviewer timed out three times on the thousand-line diff — twenty-four minutes each — before we found the right lever, and the lever had a trap in it: the label that routes a review to the paid path only takes effect on a fresh event, not on a re-run of the same one. Some dollars, metered, per online round.
Fifteen real-database concurrency tests now, up from nine. That is the number that will still matter in a year.
The fix reached production on the last day of the sprint, in a release that carried no schema change at all — the whole thing was code.
The other thing that happened
Writing a specification for how Domi should forget things, I went to check the design against the code and found the code had drifted from the premise in both directions at once: the knowledge graph had quietly started respecting “archived” while the search index still ignored it. Neither drift was wrong on purpose. Both were invisible until somebody wrote the sentence that assumed otherwise.
And the privacy review of that same spec produced the line I keep thinking about: the future “forget” feature promises a purge it cannot deliver, because the stored file bytes sit under a seven-year retention lock. So the specification now commits to never telling you something is purged when it is not. It will say what actually happens, at greater length, in worse marketing language.
Not shipped — a written decision about words we will not use. It was still the most useful hour of the sprint.