A titleless bracket showed up in a tournament listing, and 71 minutes later it was gone from production. The read-side guard did that, not the delete fix.
What the ghost was
The symptom was a bracket card with no title, no teams and nothing to click into. It was a rectangle on the page, and it looked like a rendering bug.
It wasn’t. Tournament sites and brackets are joined through a linkage row. The bracket’s display fields come from the site, so the row only knows “site X owns this bracket.” When someone deleted a tournament site, the delete removed the site and its own children, but it never touched that linkage row. The row stayed behind pointing at a site that no longer existed. The listing query walked the links, found a row, and rendered a bracket whose title lookup returned nothing.
The diagnostic path was short. An empty title field meant the join target was missing rather than blank. A missing target meant something had deleted the parent without cleaning up the children. The delete path for tournament events was the obvious suspect, so I read it, and the linkage table was not in the list of things it cleaned up. One missed backref was the whole bug.
Guard first, delete path second
There were three fixes on the table:
- Stop the render. The listing should skip any link whose site no longer resolves.
- Stop the leak. The delete path should remove the linkage row along with the site.
- Clean up. A stored-proc-level prevention plus a one-time sweep for rows already orphaned.
I shipped 1 and 2 in the same deploy. The order matters, though, because only the first one touches rows that already exist. Fixing the delete path alone stops new orphans, but every row created before the deploy keeps rendering a ghost until someone cleans the data. The read-side guard has no such dependency. It treats “link points at nothing” as “nothing to show,” so it hides every existing orphan the moment it deploys, with no data change at all.
The shape of the guard is small. Instead of trusting the link and hoping the site is there, the listing requires the site to resolve. In SQL terms that is an inner join from the linkage row to the site, where the old path effectively left-joined and rendered whatever came back. A row whose site is gone drops out of the result set before the UI ever sees it.
The part I did not finish
Item 3 did not get done. The stored-proc prevention, which enforces the cleanup at the database layer so no future delete path can forget it, and the one-time cleanup of existing orphans both need the production database. I was off my home network on an unstable connection, and I was not going to run a destructive sweep against production over a link that might drop mid-statement. A half-applied cleanup is worse than an unapplied one.
So I parked it. Both changes are staged, written up in the ticket, and set to resume from a stable network. That is the real cost of this session: the fix is complete on the read side and incomplete at the source. The orphaned rows are still sitting in the table. They are invisible now, but they exist.
Why I would ship it in this order again
A source fix answers “how did this happen.” A read-side guard answers “what does a user see while the data is still wrong.” Those are different questions, and production is always in the second situation before it gets to the first. Data that was orphaned last month, by a code path someone has since rewritten, is exactly as broken as data orphaned today. If the only defense is that nothing bad ever gets written, the first bad row that slips in becomes a visible defect.
The guard also holds up against the next bug of the same family. The delete path I fixed was one of possibly several ways to remove a site. I found the one that produced this ghost, and I cannot claim I found all of them. A listing that refuses to render dangling links is protected against the ones I did not find.
What it does not do is fix anything. The guard is a mask over bad data, and if it is the only thing that ships, the table quietly fills with rows nobody can see, and the next person to write a query against it inherits a mess. That is why the parked work matters, and why the ticket stays open until the sweep runs. The guard buys time, and the cleanup pays it back.