Six tickets had shipped to production under one epic, and its phase table said Not started in every row. Phase 0, Phase A, all of it. We had opened the epic to answer “Where do we stand on the migration?”, and the table’s answer was wrong. Both the table and the work were real. Nobody had updated the table, and we almost trusted it.
A ticket’s status field is a manual artifact. It is true for exactly one moment, the last time a human typed into it. Work moves and the field doesn’t, unless someone goes back and pushes it. Once work outruns the bookkeeping, the field reads “Not started” with the same authority whether it’s correct or three weeks stale.
The migration the table was supposed to describe
The epic is the long game on the admin stack of a live SaaS platform. The legacy Staff Center is an older web application on Windows Server 2016, which hits end of life in January 2027. That deadline drives everything. The plan replaces /admin-legacy/ with a Node plus React stack that can also ship as iOS, Android, Mac, and Windows apps through Capacitor. It’s a strangler-fig migration: the legacy application keeps serving real traffic while modern pieces get built around it and take over one slice at a time.
Progress in a strangler-fig migration is distributed. There’s no big switch to flip. A piece ships here, a service there, each with its own ticket and its own deploy. The epic is meant to aggregate all of that into one readable answer. This one hadn’t, and its phase table was frozen at the day it was written.
What was live under the “Not started” epic
So we stopped reading the table and read the shipped tickets and prod state. This was running:
- 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. - The old staff-src and
/staff-admin/artifacts stripped out of the legacy main repo. LIVE. - The text-messaging-queue folded into the internal API service’s services directory. LIVE.
That is the Phase 0 and Phase A foundation, partly in place and serving production traffic, with none of it in the table. Had we answered off the table, the report would have been “we haven’t started.” We would have said it with a straight face, because the source we trusted said it first.
The table is downstream of the work, and downstream artifacts drift. The two sources nobody hand-maintains are the shipped tickets, with their real transitions and commits, and prod state, what is running and answering requests right now. We treated the phase table as a convenience after that.
Fixing what the next reader sees
We could have edited the table and moved on. That fixes today and leaves the same drift for next month, because a hand-maintained table stays hand-maintained however recently it was touched.
“Where do we stand” came up at all because the architecture body of the epic is dense. A non-technical reader, or we ourselves three weeks from now, can’t boil the Phase 0 hard-gate items down to a one-line status. So we rewrote two human-facing surfaces:
- The Problem-Idea field got a one-sentence, speech-test answer, something you could say out loud to a person who doesn’t know the codebase and have them understand where the migration stands.
- A top comment lists what is shipped, in flight, and not started.
Now whoever asks next gets the picture without re-deriving it from architecture prose or re-trusting a table that will go stale again. Updating the field fixes the data. Updating what people read first fixes the cost of drift for every later reader, and that second job is the one that compounds.
Checking the disk before building
Once we knew where we stood, the next step was to push the Phase 0 and Phase A foundation forward. The directive was to do what’s obvious, send the parts that aren’t obvious to codex and manus, and keep iterating without stopping. That reads easily as license to start building.
Before declaring anything obvious, we looked at ground state and ran find for the linter and governance files the plan calls for. They already existed in both repos, including the tiered linter setup and parts of the scaffolding. Had we trusted our mental model of “Phase 0 hasn’t been done, so we’ll do it,” we would have rebuilt scaffolding that was already on disk. It’s the same failure as the status table, one layer down. The map said empty. The territory wasn’t.
The items still owed were the harder ones: finishing the tiered linter across Biome plus ESLint, the MCP auth boundary with principals.ts, 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, went to codex and manus in parallel as a long-running async review track while the mechanical scaffolding proceeded in the foreground. We defined “obvious” by checking disk, not by trusting the plan.
Where progress actually lives
When someone asks where a project stands, ask for the shipped tickets and the state of production first. The epic’s phase table drifts the instant work moves faster than someone updates it. When you catch the drift, fix the summary a human reads first as well as the field.
Related
- “Working” was a priority, not a status, so the queue silently 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: when one ticket hides multiple shipped pieces; splitting restores visible progress
- The Hotfix That Lied: STAGING/LIVE in JIRA, 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: 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: another case where the display order hides a product decision the data should surface