A Complaint Born Without a Trace
An empty complaint timeline was not a rendering bug: the first history row was never inserted, because twin functions CreateRegistration and CreateReport had drifted apart.
TL;DR
A new complaint had no status history because CreateReport never seeded the initial NULL-to-submitted row, while its twin CreateRegistration did. The fix wraps report creation in a transaction that inserts both the report and its opening history entry atomically. A new regression test now guards the behavior, highlighting the need to test twin functions side by side.
An Empty Timeline With a Wrong Address
The complaint detail page in the admin dashboard had an empty timeline section, while the report itself was clearly alive in the system. My first instinct pointed at the read side: a broken join to the history table, a wrong filter, something like that. I opened the network tab first just to confirm the API was really returning an empty array, not an error.
It really was an empty array. So I checked the history table directly with the report's ID. Nothing. The first row was never there. The record that should exist from second zero had never been written.
Here is where I got it wrong once. Debugging habit dragged me to the read side first, but from the outside, this report was freshly submitted, with zero admin actions so far. When the timeline of a newborn entity is empty, the first suspect should be the write side. The day-zero row is an object that is supposed to exist.
Twin Functions Drift in Silence
The cause lived in the service's repository layer, in a pair of functions that are supposed to behave alike. CreateRegistration has long seeded the initial history row, from a NULL status to the submitted status. CreateReport did not. Every complaint submission inserted the report row only, with no initial status trail.
What makes this impossible to mistake for a design decision is the fix commit's own message: "same idiom as CreateRegistration". The registration behaviour was the official reference all along, and the report path just missed it. This is what silent drift between twin functions looks like: two entrypoints that look aligned, until you notice one of them never received a change made long ago. If twins like these are not guarded by tests that compare them, the drift is only a matter of time.
OWASP describes an audit trail as a chronological record that lets you reconstruct and examine the original sequence of attributable transactions [2]. The NULL-to-submitted seed row is exactly that. Without it, the reconstruction is broken from the first second: the report sits in its initial status, the timeline is blank, and nobody can later tell "no action yet" apart from "the trail was never written".
One Transaction, or None
The fix moves CreateReport onto the same transactional pattern: open a transaction, insert the report, take LastInsertId, insert the initial history row with from_status NULL and to_status submitted, then commit. The Go documentation puts the principle plainly: a transaction groups operations so that all of them must succeed or none can [3]. A successful Tx.Commit confirms every update as a single atomic change.
That is what separates this from adding one more INSERT somewhere: the history row is now guaranteed to be born with the report or not at all. MySQL's COMMIT makes changes permanent [1], and the deferred rollback at the top of the function closes the transaction cleanly if anything fails before commit. Without the transaction, the nastiest failure mode stays available: the history insert fails while the report succeeds, the system returns to the old bug, silently, on every new submission, until an audit finds it months later.
The proof is a new regression test: submit a report, then assert exactly one history row exists with NULL as the source status and submitted as the target, and that the public ticket-status page shows the same trail. A developer who later removes the seeding gets stopped by CI, not by someone's memory in a code review.
The Keeper Lives Next Door
What let this bug live so long: the guard test only stood on one side of the pair. The submit-history test file already existed and covered the registration side, including the case where an invalid category leaves zero rows behind. Registration was watched; complaints had no assertion at all. The simple rule I keep now: whenever you write a second function that twins an existing one, copy its twin test into the same file, with a neighbouring name. When one side changes later, the diff shows which twin was left behind.
The day-zero row is invisible in every feature demo, until the day you need it. Public service systems live on trails that can be reconstructed, and a good trail starts before the first action: from the first row ever written.
Sources