---
title: "We Staged a dispatch_hours Migration and Never Ran It"
canonical: https://dxdev.com/blog/story-points-were-literally-hours/
datePublished: 2026-09-26
---
## Five Alerts, One Ticket

At 8:04 AM the brief fired into Discord five times in under two minutes, all five for the same ticket. That was the flap we sat down to fix. Buried in the noise was a second alarm, wrong in a more interesting way: a warning that the day's story points didn't reconcile with the hours logged against them.

The five-way flap was the easy part. The brief dispatcher retried on any response slower than its timeout, and it never checked whether the first attempt had landed before firing the second. Four of the five messages that morning were retries of a Discord webhook that had succeeded the first time, just slowly. The fix was an idempotency key, the ticket id plus the minute bucket, checked before send. One line of logic, and the flap stopped.

The story-points alarm was the one we should have looked at first, and didn't.

## What the alarm was comparing

The brief pulled two numbers for the day: total story points closed, and total hours logged by the platform's dispatch system against those same tickets. If the ratio drifted more than a set band from 1:1, it flagged a mismatch and printed both totals into the 8am brief. Most days it fired. Nobody trusted it, so it got ignored, which is exactly the kind of alert that should not exist.

We went in with the standard assumption: points are an abstract measure of complexity, hours are wall-clock time, and you shouldn't expect them to converge cleanly. If leadership wanted a real point-to-hour comparison in the brief, the fix was to give tickets an actual hours field the dispatch system could write into cleanly, separate from whatever `story_points` had been holding.

So we built it. A migration adding a `dispatch_hours` column to the ticket table, a backfill script pulling elapsed time from dispatch logs, all of it staged and ready to run against three months of history. We ran the backfill on a scratch copy first, and the numbers it produced were identical to `story_points` out to two decimal places on every single row.

Not close. Identical.

## The column already held hours

That sent us back into the schema instead of forward with the migration. The `story_points` field was never being written by a person choosing a Fibonacci number off a board. The platform dispatch wrote it, and the value was minutes worked divided by sixty, rounded to one decimal. Whoever built the original dispatch integration needed a numeric field to populate automatically and reused the one already on the ticket schema, because adding a new column felt like more work than it was worth. Nobody renamed the field, and nobody downstream went back to check what was landing in it.

So the debate the team had been circling for months, whether the platform needed real time-tracking granularity on top of story points, had been settled since the day that field was reused. We had two names for one number, and we had been treating the mismatch alarm as a data quality problem when it was a naming problem.

We threw out the migration and the backfill script. There was nothing left to build. We renamed nothing either, since a rename would have broken every downstream report reading `story_points`, and the field was doing its job under the wrong label.

## What shipped

The fix was smaller than the investigation. Every platform dispatch now writes its actual cost against the ticket explicitly, formalizing what `story_points` had been doing implicitly. The false alarm came out of the brief entirely, since comparing a number against itself was never going to produce a signal. We wrote up the finding as a standing analysis doc rather than a ticket, because there was no work item to attach it to. The question was closed.

The session ran five hours eleven minutes, most of it on a migration that never shipped. The part that mattered was running the backfill against scratch data before touching production. That run surfaced the identical numbers and stopped us from adding a second column that duplicated a field we already had. Had we trusted the assumption instead of checking the join, we'd be running two hour-tracking fields today, both correct, and eventually disagreeing with each other without anyone noticing.

Before paying for a new field or a migration, check what the existing field actually holds. An alert that fires most days and that nobody trusts deserves a look at what it compares, because the mismatch may come from a label and not from the data.
