The thing you sell is not the thing on the map
The double-booking check was written against the wrong row, and the way it surfaced was a booking calendar that came up empty. Not wrong — empty. Bars were drawn from per-site booking lines, and the list endpoint returned only the line’s identifier and its site, so every line arrived without a start or end date. The calendar skipped every line that had no dates, which was all of them, and rendered nothing.
That was a two-line fix. The reason it is worth writing about is what the fix exposed underneath.
Two different things were both called a booking
The system had a booking header — one client, one date window, one rate, and a single site identifier — and it had per-site booking lines, which existed in the schema with associations declared and were plainly the intended mechanism for a campaign covering several hoardings at once.
The write path used the header. The line table was written by conversion and by import, read by the calendar and the availability queries, and ignored entirely by the code that decided whether a booking conflicted with another. So the conflict check compared header against header, on a single site identifier, while the thing that actually recorded which surfaces were committed for which dates was the lines.
A campaign covering four hoardings had one header, one site on it, and four lines. Three of those four surfaces were, as far as the conflict check was concerned, free.
The unit of sale is the surface and the window
The correction is a modelling one and it generalises well past outdoor media. The entity you draw on a map is not the entity you sell. A location has an address, a photograph, coordinates, an owner, a lighting type. None of those are what a client buys. A client buys a surface, for a window, at a rate — and one location can carry more than one surface, so those are separate rows with their own dates.
Once that is stated, several things that felt like separate problems collapse into one:
- The conflict check belongs on the line, keyed by surface and date window, because that is the row that represents a commitment.
- Availability is a property of a surface over a window, not of a location.
- The rate build-up belongs on the line too, because that is where a price meets a specific surface and can therefore know whether it is lit.
- A campaign that runs on four surfaces produces four rows a field team can be sent to and four sets of proof photographs, which is what the operations side needs anyway.
Every one of those had been attempted on the header at some point, and every one of them had a qualifier attached to make it work — take the first site, take the header rate, assume one photo set. When a model needs “take the first” to function, the model has the wrong grain.
What I assumed and had to unpick
I assumed the lines were the newer, correct mechanism and the header field was legacy, so the plan was to move logic onto the lines and stop reading the header.
That was backwards in the way that matters. The lines were the better mechanism and the unused one on the write path. The header field was legacy and live. Shipping the correct rule onto the dormant path would have produced a conflict check that never ran, on a system whose users had been told double-booking was now prevented.
So the sequencing became: put the rule where the writes are, mirror the fields onto the better structure at the same time so they cannot drift, then migrate the write path, then delete the header field. Three steps instead of one, and the only reason for the extra two is that a rule which does not execute is worse than a rule that is known to be wrong. A known-wrong rule gets worked around by the people using it. A rule that silently does not run gets trusted.
The empty calendar was the useful symptom
The reason I want to keep the empty calendar in this story rather than treat it as trivia is that it is the shape most modelling errors take when they finally show themselves. Nothing threw. No error was logged. A screen rendered its frame, its filters, its legend and its key figures, and the area where the data goes was blank.
A list endpoint had been trimmed to the fields one caller needed. A second caller needed two more, and its failure mode for a missing field was to skip the row. Neither side was unreasonable. The defect lived in the gap between them, which is exactly where a modelling confusion lives too: the header knows about a site, the lines know about surfaces and dates, and any code that has to reconcile the two will do it with an assumption that is silent when it is wrong.
Decide what a single sellable unit is before you write the first constraint, and put every date, rate and conflict rule on that row. If the answer to “which row means this is committed?” is “it depends which query you ask”, double-booking is already possible and you have not seen it yet.
The limit I still carry: on the version that shipped, the header field is present and populated, and one report path still reads it. It is correct today because conversion writes both. It stays correct only for as long as nobody adds a third write path.