---
title: "The epic said \"Not started\" while six tickets shipped under it: reading progress past a stale status table"
canonical: https://dxdev.com/blog/epic-said-not-started-while-six-tickets-shipped-under-it/
datePublished: 2026-05-26
---
"Where do we stand on the migration?" I opened the epic to answer that, and its own phase table said *Not started* across every phase. Phase 0, Phase A, all of it. Not started. Meanwhile six tickets had already shipped to production underneath that same epic. Both the table and the work were real. The table just never got updated, and I almost trusted it.

That gap is worth dwelling on, because it is not a one-off. The status field on any ticket is a manual artifact. It tells you the truth about exactly one moment: the last time a human typed into it. Work moves. The field doesn't move with it unless someone goes back and pushes it. The instant your work moves faster than your bookkeeping, the field starts lying, and it lies with total confidence. "Not started" reads exactly as authoritative whether it's correct or three weeks stale.

## The migration the table was supposed to describe

The epic here is the long game on the sports SaaS's admin stack. The classic Staff Center is classic ASP running on Windows Server 2016, and Windows Server 2016 hits end of life in January 2027. That's the hard deadline driving everything. The plan is to replace `/admin-legacy/` with a Node plus React stack that can also ship as iOS, Android, Mac, and Windows apps through Capacitor. Strangler-fig: the ASP keeps serving real traffic while modern pieces get built around it and eat it one slice at a time.

Strangler-fig migrations are exactly the kind of work where a status table goes stale fastest, because progress is distributed. You don't flip one big switch. You ship a piece here, a service there, and each piece is its own ticket with its own deploy. The epic is supposed to be the place that aggregates all of that into one readable answer. When it doesn't, you get what I got: a phase table frozen at the day it was written.

## What the stale table was hiding

So I stopped reading the table and started reading the shipped tickets and prod state instead. Here's what was actually live under that "Not started" epic:

- Staff Center v2, React 19 plus Vite plus Tailwind plus shadcn, serving at `/staff-admin/`. LIVE.
- Reverse proxy plus the internal API service running in prod. LIVE.
- The internal API service moved off NSSM onto WinSW for its service wrapper. LIVE.
- The internal monorepo created, `/staff-admin/` deploying via GitHub Actions and a self-hosted runner. LIVE.
- Stripped the old staff-src and `/staff-admin/` artifacts out of the legacy main repo. LIVE.
- Folded the text-messaging-queue into the internal API service's services directory. LIVE.

That is the Phase 0 and Phase A foundation, partially in place and serving production traffic. None of it was reflected in the phase table. If I'd answered "where do we stand" off the table, the honest report would have been "we haven't started," and that would have been a lie I told with a straight face because the source I trusted told it to me first.

The lesson that generalizes: an epic's status field is downstream of work, and downstream artifacts drift. The upstream truth lives in two places that don't lie because nobody hand-maintains them. The shipped tickets, with their real transitions and their real commits. And prod state, what is actually running and answering requests right now. Read those. The phase table is a convenience, not a source.

## The fix is not "update the table." It's "stop the next reader re-deriving it."

I could have edited the phase table, called it done, and moved on. That fixes today and guarantees the same drift next month, because a hand-maintained table is a hand-maintained table no matter how recently you touched it.

What I did instead was aim at the human-readability gap directly. The reason the question "where do we stand" even reached me is that the architecture body of the epic is dense. A non-technical reader, or me three weeks from now, cannot parse Phase 0 hard-gate items into a one-line status. So I rewrote two human-facing surfaces:

1. The Problem-Idea field got a one-sentence, speech-test answer. Something you could say out loud to someone who doesn't know the codebase and they'd understand where the migration stands.
2. A top comment listing shipped, in-flight, and not-started explicitly.

Now the next person who asks "where do we stand" gets the picture without re-deriving it from architecture prose, and without re-trusting a phase table that will be stale again by then. The summary lives where a human looks first. That's the actual deliverable, not the corrected table.

This is a pattern worth naming. When you catch a drift, the cheap fix updates the stale field. The durable fix updates the thing a human reads, so the drift stops costing future readers their time. Fixing the data and fixing the readability are different jobs. The second one is the one that compounds.

## Don't scaffold what's already scaffolded

There's a tail to this. Once I knew where we actually stood, the next move was to push the Phase 0 and Phase A foundation forward. The directive came down to: do what's obvious, send the parts that aren't obvious to codex and manus, keep iterating without stopping. Easy to read that as license to start building.

But the same discipline that caught the stale table applies to "obvious work." Before declaring anything obvious, I went and looked at ground state: actually `find` the linter and governance files the plan calls for. They already existed in both repos. The tiered linter setup, parts of the scaffolding, already there. If I'd trusted my mental model of "Phase 0 hasn't been done, so I'll do it," I'd have rebuilt scaffolding that was sitting on disk. Same failure as the status table, one layer down. The map said empty. The territory wasn't.

The Phase 0/A items still genuinely owed were the harder ones: the tiered linter completion across Biome plus ESLint, the MCP auth boundary with `principals.ts` and verifier middleware and negative tests, a CodeQL baseline, Semgrep plus `npm audit --audit-level=high` plus gitleaks, and a `docs/rotation.yml`. The load-bearing one, the MCP auth boundary, got dispatched to codex and manus in parallel as a long-running async review track while the mechanical scaffolding proceeded in the foreground. The not-obvious stuff went to the reviewers. The obvious stuff got done. But "obvious" was defined by checking disk, not by trusting the plan.

## The takeaway

An epic's status field is a manual artifact, and it drifts the instant work moves faster than someone updates it. When you need to know where something stands, don't read the phase table. Read the shipped tickets and read prod state, because those don't lie. And when you find the drift, don't just patch the field and move on. Fix the human-facing summary, the Problem-Idea and a top comment, so the next person to ask "where do we stand" gets a straight answer instead of re-deriving it from architecture prose or re-trusting a table that's already stale again. The same check, look at ground state before you trust the description of it, is what keeps you from rebuilding scaffolding that's already on disk. The status field is a convenience. The shipped work is the truth.

## Related

- ["Working" was a priority, not a status, so the queue silently grew to 65](priority-drifted-from-status-working-queue-grew-to-65): the same status-field drift problem at the ticket level; the field stopped matching the actual state
- [The omnibus ticket is a coordination tax: splitting one QA pass into six](omnibus-qa-ticket-is-a-coordination-tax-split-it): when one ticket hides multiple shipped pieces; splitting restores visible progress
- [The Hotfix That Lied: STAGING/LIVE in JIRA, Never Merged to Master](jira-staging-live-never-merged-to-master): the tracker said one thing; git said another; always read the artifact, not the status
- [When AI Makes Analysis Cheap, Your Decision UI Becomes the Bottleneck](jira-cockpit-ai-makes-analysis-cheap-decision-ui-is-bottleneck): the broader problem: when the tracker lies, even good analysis is answering the wrong question
- ['Newest First' Is a Re-Priority Problem, Not an ORDER BY](newest-first-is-a-repriority-problem-not-an-order-by): another case where the display order hides a product decision the data should surface
