The soft delete was the bug, not the safety net
Removing a driver from a booking used to blank the driver reference on the assignment row and leave the row in place. The reasoning behind that is familiar and sounds responsible: never destroy data, keep the record, you might want it later.
What it produced was a husk. A row that exists, belongs to a leg, and names nobody. Combined with read paths that did not filter soft-deleted rows, those husks were the source of phantom assignments on eighteen legs. And they destroyed the very thing they were meant to preserve — blanking the driver reference in place is exactly how seven rows lost the identity of the person who had been removed, permanently.
The owner’s model, when he stated it plainly, was simpler than what had been built: one row per leg holding the latest driver, changes recorded in the audit log, soft delete redundant. Removal deletes the row. The log entry is the record.
He was right, and the reason is worth generalising. A soft-deleted row is a second, weaker copy of the truth living in the same table as the first. Every read now has to know about the flag. Every join has to carry it. Nine read paths in this codebase did not, and each one was an independent opportunity to get it wrong. An audit log has none of those properties: nothing joins to it by accident, and no query returns a log entry when it meant to return an assignment.
I had started building the opposite thing — a read-time fallback that would surface historical assignments with an “is historical” flag on every office screen. The owner’s clarification made that the wrong shape, so it was reverted back to the deployed code rather than shipped as complexity the model does not need. The right response to being told your design answers the wrong question is to throw it away the same afternoon.
The tests earned their keep on the first run
A hard delete on a table with children is the sort of change that ought to be tested, and this one was, with an end-to-end suite driving the real portal against the real API.
The first run failed immediately: deleting an assignment that has handover photos returns a 500, because the photo table has a foreign key with no cascade behaviour defined.
Read that failure carefully, because it has two layers. The obvious layer is that deallocation would have broken — and specifically it would have broken on assignments that have photos, which is to say on the bookings where a driver has actually done the job. The bug would have been invisible on fresh test bookings and universal on real ones.
The second layer is worse. If the constraint had been written with a cascade, the delete would have succeeded and taken the photos with it — every photo the driver took at the handover, and the record of who took them. The 500 was the schema refusing to let a design mistake destroy evidence.
Removal is now refused outright when photos exist, counting all photo rows including deleted ones, since the constraint ignores the deleted flag anyway. The justification is a business one, not a technical one: photos mean the driver already did the job, and changing your mind about who did a job that is already done is not a removal. It is a re-assignment, which still works.
A foreign key constraint that blocks you is doing its job. The instinct is to add a cascade and move on. Ask first what the cascade would delete and whether anyone would ever want that.
Undefined is not null, and this is where it matters
The other defect the design invited was subtler.
If removing a driver means “the request carried no driver”, then any request that omits the field is a removal. The mobile app posts status-only updates on handover — the driver finishes the job and the phone sends the new status and nothing else. Under a naive rule, completing a handover would delete the assignment of the driver who had just completed it.
Removal is therefore gated on an explicit null. Undefined means “not supplied” and is ignored; null means “set this to nothing” and is honoured.
That distinction exists in most languages and most serialisation formats, and it is routinely collapsed, usually by a helper that treats both as absent. In any partial-update endpoint, absent and explicitly-null are two different instructions and must stay different all the way from the wire to the handler. The moment one utility function conflates them, every field in that endpoint inherits the bug, and it will be found by whichever client happens to send the smallest payload.
What the test suite could not test, and how that was found
The suite passed. The owner then asked whether assignment from the booking detail screen was covered, and it was not.
The portal spec had set the state up over the API and then asserted on what the screen displayed. So it tested the surroundings of the reported symptom rather than the reported action. The action — open the assign control, pick a driver, submit — had never been driven.
A second spec was added that drives the real interface, and it asserts something specific: that the driver’s contact link becomes a real link addressing that driver without a page reload. The reload matters. Reloading re-fetches everything and would mask the exact defect being tested, which was that the write landed on a row the screen does not read. It also asserts the other leg stays unassigned, so the write cannot bleed across.
A test that arranges its state through a different door from the one users walk through is testing the room, not the door. Where a bug was reported against an interaction, the regression test has to perform that interaction.
Finding the controls needed a throwaway probe spec, because the button is not labelled what anyone assumed and it opens a modal rather than the overlay it was thought to open. That probe was deleted afterwards. There is no shame in writing a script whose only purpose is to tell you what is on the screen.
One flake was self-inflicted and worth naming: two specs both assigned drivers to the same discovered booking, and the runner executes files in parallel workers. They now take disjoint bookings, lowest id and highest id. A test that discovers its own fixture has to also reserve it.
Two incidental findings, recorded rather than fixed
Two things surfaced during this work that had nothing to do with it, and both were written down rather than silently repaired.
The assignment table’s vehicle reference points at a different identifier space from the booking’s vehicle reference — two columns with the same name, joining to two different keys on the same table. On one test booking they read 43 and 92. That one nearly produced a much larger false alarm during a separate investigation, where a naive join suggested all 5,193 assignments disagreed with their booking’s vehicle; the real figure was 103.
And a delete endpoint that the portal never calls looks up its row by the upstream reference with no leg filter at all, so it can destroy the pick-up when the caller meant the drop-off. It was left alone, because narrowing it would change a public contract for no current benefit, and flagged instead.
Fixing everything you find in one pass is how a two-day change becomes a two-week change with an untestable diff. Write it down, say why you did not fix it, and move on.