Our product used to be one thing: a business chats with us on WhatsApp and gets a website. So the natural place for the lifecycle — draft → demo → paid → published → live — was a single status column on the customer's record. Everything read it: the public directory, the admin filters, the follow-up nudges, the metrics.

Then the product grew. A business can now want a website and a paid audit and a content migration — each its own piece of work, each at its own stage. A single status column can't hold three lifecycles. The lifecycle had to move off the shared row onto a new per-product model.

That left a practical problem: how do you move a column that many queries still depend on, without a migration that rewrites every reader at once?

The wrong turn we almost took

The tidy-sounding answer is "make the old column a property." Leave status in the code, but have it compute its value from the new model on the fly. No data move, no divergence.

It doesn't survive contact with the database. A computed property lives in your application language; the query layer can't see it. The moment you write filter(status="live") or values_list("status") — which the directory, the nudges, and the metrics all do — it breaks, because there's no column there to filter on anymore. A property fixes the dozen places that read the attribute and breaks the dozen places that query the column. You've moved the breakage, not reduced it.

Keep the column. Make it a mirror.

The move that made this boring: the new model becomes the source of truth, and the old column stays — as a mirror of it.

One piece of code now owns every state change. When it advances the new model, it writes the old column too, in the same breath, to the same value. The column is no longer truth; it's a copy of it, kept in sync by that one write path.

        ┌──────────────────────────────┐
writers │  the one orchestrator         │
   ──▶  │  advance(state)               │
        │    ├─ new model .state  (truth)│
        │    └─ old column        (mirror)│
        └──────────────────────────────┘
                     │ both always equal
                     ▼
            readers query the column ── unchanged, still correct

Now the migration inverts. You don't touch the readers at all — they keep querying the column and keep getting right answers, because the column always equals the truth. You only touch the writers, and there are far fewer of them.

One writer at a time, against a green suite

We had about five places that advanced the status: a payment webhook, the publish step, the build's "done" hook, a domain check, the payment-link command. We converted them one at a time, each to route through the single orchestrator that writes both.

After every single conversion, the full test suite stayed green — and that's the quiet point. The observable outcome (what value the column ends up at) didn't change, so the tests that already asserted "after payment, status is paid" kept passing without edits. The existing suite kept us honest for free: it already pinned the behavior, and any conversion that drifts it lights up red immediately.

The one bug it surfaces — and that's a feature

One test failed, and it told us exactly the right thing. It set the old column directly, bypassing the orchestrator — so the new model said one thing and the mirror said another. They'd diverged.

That's the whole discipline of a mirror, surfaced as a failing test: once you adopt one, the column may only ever be written through the single path that keeps both in sync. A raw write anywhere else re-opens the gap you closed. The fix wasn't code; it was the rule. (We grepped for stray writers afterward — there were none left among the remaining writers.)

What we took away

A mirror column turns a frightening all-at-once migration into a sequence of small, reversible steps. Truth moves to the new model; the index follows automatically; the readers don't have to change. The only genuinely risky step — actually dropping the old column — you get to defer indefinitely, because a mirror is cheap to keep around while you make sure nothing still depends on the column.

We migrated the writers, not the readers. That asymmetry is the whole trick.