---
name: legacy-migration
description: Characterize a legacy application's database behavior and turn it into portable golden-master test fixtures, using an OinoCloud MCP server's capture/diff/webhook tools, so the app can be rewritten and verified against its real observed behavior. Use when migrating or rewriting a legacy app whose test DB is (or can be) connected to OinoCloud and you want behavior-preserving tests rather than hand-written ones.
---

# Legacy migration — behavior capture & golden-master fixtures

This skill choreographs a **characterization-testing** workflow for migrating a legacy application: instead of trusting a hand-written spec, you record what the *real* legacy code does to the database, action by action, and turn those recordings into runnable golden-master fixtures. You then rewrite the app and check it against those fixtures **locally, on the collected data** — independent of the legacy environment, so iteration is fast.

The OinoCloud MCP server is the **platform** (the capture/diff/coverage/fixtures tools). This skill is the **choreography** over it. It does not itself modify the legacy app or its data — it reads, captures, and guides.

## What this gives you, and what it does not

- **Oracle = the database delta.** For each instrumented action, the fixture records `(pre-state → action → expected row-level delta)`. That is a *strong but partial* oracle: two code paths can leave identical DB state yet return different HTTP responses, send different emails, or make different outbound calls. Treat DB-delta equivalence as the primary signal, not proof of full behavioral equivalence. Say this to the user plainly; do not claim a migration is "safe" because fixtures pass.
- **The DB and the HTTP response can disagree, and that is the point.** Post-commit hooks routinely rewrite rows *after* the response is serialized, so a response-based test can pass while the database diverges. Expect to find these; they are the strongest argument for this whole approach. Report them explicitly when you do.
- **Coverage is *observed*, never complete.** You can report "these tables/stories/transitions were exercised, these were not." You cannot prove you found every path from source alone — dead code, admin/back-office flows, cron/batch jobs, and undocumented triggers get missed. Frame coverage as observed coverage; never as "all scenarios covered."
- **Determinism is the #1 practical hazard.** Volatile columns (`created_at`, `updated_at`, autoincrement PKs, GUIDs, `now()`, sequence numbers) make identical logical actions produce different bytes and will make fixtures flap. Identify these per table up front and pass them as `ignoreColumns` on every capture/diff so they are normalized out. This is not optional polish — it is what makes the goldens stable.
- **Some columns cannot be verified at all.** The capture representation is CSV, so JSON/JSONB columns may serialize to an opaque placeholder with no recoverable value. Identify these per table during step 2, exclude them from assertions, and tell the user which columns the oracle is blind to.

## Prerequisites

1. **The legacy *test* DB is connected to OinoCloud as a customer database**, with an MCP server bound to it. Confirm with `list_tables` / `get_table_schema`.
2. **Collection and verification need different rights — decide both now.**
   - *Collection* should use a read-only-data MCP server where possible: `allowSchemaManage=false` **and** `allowDataManage=false`. The whole capture path only reads the DB (captures + diffs go to OinoCloud's blob store, never the customer DB), so read-only both fits and limits blast radius. `list_tokens` on such a server returns `dataManage:false` and only read tokens.
   - *Verification* needs to **write**: restoring a pre-state and, critically, resetting identity sequences. Establish before you start how the user will do that — direct DB access, a second MCP server with restore rights, or the chain-replay technique in step 6. Do not assume "locally" implies they have a database connection string; when the DB is hosted and the only path in is OinoCloud, it does not.
   - Note that CRUD data-API tokens alone cannot `TRUNCATE` or `setval` a sequence. **This no longer blocks verification:** since MCP server 1.10.0 `export_test_fixtures` canonicalizes ids (see step 6), so goldens compare across baselines *without* any sequence reset. Treat direct-DB `setval` as an optional accelerant that removes ordering ambiguity where you happen to have it — never a prerequisite. This matters because the common hosted case (verify through OinoCloud, seed via the data API) has no way to `setval` at all.
3. **Quiesce the test DB during capture** — ideally one actor at a time. Background jobs or concurrent users mutating the DB mid-action contaminate the delta and produce `unsettled` captures (see §"Premature complete"). If the app can't be quiesced, capture *between* actions rather than trusting tight brackets. Also expect real capture DBs to be **dirty** (already carrying data), so fixtures will carry whatever ids that DB was sitting at — which is exactly why the goldens are canonicalized rather than compared as literal ids.

## The workflow

### 1. Snapshot the baseline, and check the DB can actually run your stories

**Before instrumenting anything**, take a full `snapshot`. Keep it with the fixtures. It is three things at once:

- **The replay root.** Fixtures reference their pre-state by blob ref; a local copy of the starting state is what lets the user restore and replay without depending on that storage being reachable.
- **A precondition check.** Read the row counts. A test DB that is freshly migrated or freshly restored is often *empty of the data the stories need* — no catalog, no configuration, no users. Capture will then be silently impossible, and you will not discover it from the instrumentation side. Verify the stories' inputs exist before writing a line of middleware.
- **What makes the determinism re-run possible.** Validating `ignoreColumns` means walking the same stories twice from the same pre-state and diffing. Without a baseline you cannot get back.

If the DB lacks the data the stories need, seed it **through the application's own API with instrumentation already in place**, so the seeding is itself captured. Seed writes are real application behavior the rewrite must also implement, and an empty post-install state is a far better fixture root than a hand-made one: it is exactly reproducible, and every later fixture chains from it.

### 2. Analyze the legacy codebase → user stories
Read the legacy source and enumerate the **user stories / actions** that change persistent state — the request handlers, jobs, and admin flows that write to the DB. For each, note the entry point (handler) and the outermost return. Keep a stable `storyId` per story; you will thread it through instrumentation and coverage. Cross-reference against the live schema (`list_tables`, `get_table_schema`) so you know which tables each story is expected to touch.

### 3. Write the test spec — and make it machine-readable

For each story, describe the scenario as a DB-state-change: preconditions, the action, and the tables expected to change. Record, per table, four things — they drive everything downstream:

- **`ignoreColumns`** — volatile columns to normalize out (timestamps, GUIDs).
- **`unverifiable`** — JSON/JSONB columns the CSV oracle is blind to.
- **`idRefs`** — which columns are foreign keys and to which table: `{ column: { table: referencedTable } }` (a bare table-name string is also accepted). This is what lets `export_test_fixtures` remap references when it canonicalizes autoincrement ids, and it is the one piece the server cannot infer: legacy schemas routinely declare no FK constraints, and without `idRefs` a multi-table delta is only PK-symbolized and stays non-portable across baselines. Enumerate every FK column of every touched table.
- **`derivedColumns`** — columns whose value is computed from a row's own id (e.g. `order_number = 10000 + order_id`). List them so the server excludes them from the canonical delta/hash (otherwise they'd read as a false diff every time the id shifts), and record their invariant so your local runner can assert it — that is what still catches a genuine numbering regression.

**Emit this as a `stories.json` keyed by `storyId`, and deliver it alongside the exported fixtures.** A fixture carries a `storyId` and a route template and nothing else: no description, no preconditions, no expected tables. On its own the id is an opaque string, and the plan is what gives it meaning. If the plan does not travel with the fixtures, it will drift or be lost.

**A `storyId` names what was attempted, not what happened.** A failed attempt and a successful one on the same route share the id, so coverage counts conflate them and a reader cannot tell six identical-looking captures apart. Failure cases are *valuable* — a validation rejection with an empty delta is a real fixture — so do not drop them; distinguish them. Either give each variant its own id (`order.place.zero_total_wrong_method`) or record an explicit outcome/variant alongside the story. Decide the convention here, before instrumenting.

### 4. Instrument the legacy app (add webhook calls)
For each state-change point, mint a **static ingest URL** with `get_webhook(webhookId)` — pick a stable `webhookId` per point (reuse it across redeploys; prefix for scoping, e.g. `"staging:order_create"`). The returned URL embeds an HMAC token and stays valid for the life of the MCP token, so it can be baked into the code once and the app redeployed and left running for as long as data collection takes.

Guide the user to POST to that URL at action boundaries (the app is the caller; no auth header needed — the token is in the URL):
- `{correlationId, phase:"start", storyId}` at **handler entry**.
- `{correlationId, phase:"complete", storyId, ignoreColumns:[…]}` **once** at the **outermost return** (a `finally` after the response is ideal).
- `correlationId` is a fresh unique id per action *instance*, threaded from start to complete.
- `phase:"point"` is available for an ad-hoc state marker outside an action bracket.

The first `complete` per `correlationId` captures the DB state and diffs it against that action's `start`. Duplicate completes are ignored. **This instrumentation step is the only change to the legacy code, and it is additive** — the signature-verified receiver is the only OinoCloud-side addition.

**Record the request payload if you can.** Fixtures capture the *effect* but not the *cause*: `when` holds a route template and a correlation id, with no body or resolved parameters, which makes replay from a fixture alone impossible. The handler has the request in hand at `start`. Send a redacted copy — and redact deliberately, since auth and customer-creation bodies carry credentials. If the platform cannot store it yet, maintain the request list as a separate ordered file and deliver it with the fixtures; say plainly that it is hand-maintained.

**Prove the bracket actually fires before walking any stories.** Instrumentation that is loaded but never executed is the expensive failure mode: frameworks drop middleware for dependency-ordering and registration reasons without logging anything, and the inert path is silent by design. Make the instrumentation report on itself through a channel you can observe directly — a response header is ideal, since you can read it from the caller — and confirm one bracketed request produces one capture. Do not trust application logs for this: log level, log destination and buffering frequently hide the output. Only then start the walk.

### 5. Drive capture + read coverage
Open a session with `start_capture_session(sessionId)` (records the window), then have the user exercise the stories against the quiesced DB. As the app fires webhooks, captures accumulate in the session-agnostic stream and are associated to the session by time window.

- `session_status(sessionId)` → the `coverage` rollup: which `storyId`s have been exercised, how many captures each, which webhooks. Use this to tell the user **observed** coverage — "stories A, B, C exercised; D, E not yet."
- `list_captures({sessionId, storyId?, webhookId?})` → capture summaries (each with a compact per-table changed-count).
- `get_capture({webhookId, correlationId})` → one capture's full row-level diff, to inspect exactly what an action changed.
- For a manual baseline or an ad-hoc before/after outside the webhook path, `start_capture_session` + `capture` + `diff_captures` still work.
- Close with `close_capture_session(sessionId)` when the walk-through is done (fixes the window's upper bound).

Drive the walk as a **single ordered chain**, one action at a time, waiting for each to complete before the next. Sequential ordering is what makes step 6's chain replay possible, and concurrent brackets contaminate each other's deltas.

Watch for captures flagged `unsettled` (see below) and re-drive or quiesce those stories.

### 6. Export fixtures → hand off for local iteration
`export_test_fixtures({sessionId, idRefs, derivedColumns})` (or scoped by `storyId`/`webhookId`/time) emits the portable golden-master set. **Pass the `idRefs` and `derivedColumns` from your step-3 `stories.json`** — the server reads minted-ness and the PK column from the live schema itself, but the references and id-derived columns are yours to supply. Each fixture's `then` now carries:

- **`expectedDelta`** — the *canonical* delta: engine-minted ids replaced by `⟨table#n⟩` symbols and references remapped to them, so it is identical across baselines. This is what you assert against.
- **`canonicalDiffHash`** — a single sha256 over the canonical delta; comparing this is the cheapest assertion and all you need for a pass/fail.
- **`unresolvedRefs`** — reference values left literal because they point into pre-state (surfaced, not hidden — see below).
- **`expectedDeltaLiteral`** — the raw pre-canonical delta, for debugging only.
- **`expectedEtagAfter`** — literal after-state etags; meaningful *only* under exact-restore, so ignore it unless you deliberately reset sequences.

The bundle's `manifest.json` carries the **`identity`** the server used (per-table `idColumn`/`minted`/`references`/`derivedColumns`). `unsettled` captures are skipped by default. The bundle arrives as a downloadable archive — write it to disk and work from the files.

Deliver to the user, as one set: the fixtures, the step-1 baseline, the step-3 `stories.json`, and the ordered request list from step 4. **Verification is done locally against the collected data, not through this MCP** — the fixtures are the framework-agnostic contract; render them into whatever test runner the rewritten stack uses.

**Chain replay is a convenience, not a requirement.** If the walk was a single ordered chain, every fixture's pre-state *is* the previous fixture's post-state, so you can restore the step-1 baseline once and replay in order rather than restoring each fixture's pre-state from its blob ref. And where you *do* control the DB directly, `setval`-ing sequences after a restore makes ids line up literally, which removes ordering ambiguity between two otherwise-identical inserted rows. Both are accelerants on top of canonical comparison — neither is needed for correctness, and neither is available in the hosted case, which is why canonical comparison (above) is the baseline path.

**Compare canonical form, not literal ids — and let the platform compute it, don't reimplement it.** The rewrite will allocate different autoincrement ids than the legacy capture did; that is expected and is exactly what canonicalization absorbs. To verify one action: canonicalize the rewrite's observed delta *the same way the fixture was canonicalized* and compare. Two ways, both of which keep the calculation out of your code:
- **Hosted (paths a/b):** capture the rewrite's action through the MCP as well, then run `export_test_fixtures` on the rewrite's session with the **same `idRefs`/`derivedColumns`**, and compare `canonicalDiffHash` per story between the two fixture sets. You are only comparing strings.
- **Local (path c):** call the shipped `canonicalizeStateDiff(rewriteDelta, identity)` from `@oino-ts` / `shared/lib/oino` — passing the `identity` straight from the fixture bundle's `manifest.json` — then compare its `canonicalDiffHash` (or the delta) to the fixture's. You call one library function; you never hand-write the id-symbolization logic.

**Assert `derivedColumns` invariants explicitly.** Canonicalization drops those columns so they can't cause a false diff, which also means a real numbering regression won't show up in the delta. Evaluate each recorded invariant (e.g. `order_number == 10000 + order_id`) against the rewrite's literal row in your runner — this is where that class of bug is caught.

**Check `unresolvedRefs`.** A non-empty list means a reference pointed at a row that already existed in the capture pre-state (e.g. a `cart_id`), so it stayed literal. If a verification comparison fails and the difference is in an unresolved-ref column, the cause is usually a pre-state that wasn't restored identically — not the rewrite. Restore the same pre-state, or add the missing `idRefs` if the column is actually a foreign key you forgot to declare.

**Diff locally rather than through the capture webhook.** The webhook exists to characterize the *legacy* app. For verification, snapshot the DB directly before and after each action and compute the same normalized delta — otherwise the rewritten app has to carry the capture middleware just to be testable.

**Validate `ignoreColumns` by walking twice.** Replay the whole chain a second time from the same baseline and diff the two runs. Any column that differs between two runs of the same action is a volatile column the list missed. Until this is done, the fixtures are single-observation and the normalization is unproven — say so.

## Premature `complete` / unsettled captures
"First complete wins" is only *correct* if the DB is settled by that first complete. Async writes, background jobs, or a `complete` marker at the wrong boundary freeze a partial after-state as golden. Mechanical dedup of duplicate completes is automatic; detecting a *non-settled* complete is not fully automated yet. If a story has fire-and-forget writes or no single return point, it won't yield an honest bracket — drop it to quiesced, capture-between-actions mode instead of trusting the webhook bracket. When unsettled detection exists, exclude `unsettled` fixtures (the default) and re-capture those stories.

Queue and job tables deserve special care: a worker draining a queue mid-bracket makes the same action produce a different delta each time. Exclude such tables from assertions rather than trying to normalize them — and note that `ignoreColumns` normalizes *columns*, not tables, so table-level exclusion has to happen when you compare, not when you capture.

## Honesty checklist (state these to the user)
- Coverage reported is **observed**, not exhaustive.
- The DB delta is a **strong but partial** oracle; response/side-effect equivalence is not captured here.
- Fixtures are only as stable as the `ignoreColumns` normalization; a flapping fixture usually means a missed volatile column, not a real regression. If the walk has only been run once, that normalization is unvalidated.
- Name the columns the oracle cannot see (JSON/JSONB) rather than letting them look verified.
- A fixture without its plan entry and its request payload is not replayable; deliver all three together.
- Capture requires a **quiesced** test DB; concurrent mutation contaminates deltas.
- Canonical goldens are only as good as `idRefs`: an undeclared foreign key stays a literal id and makes that fixture non-portable. A non-empty `unresolvedRefs` on a fixture is the signal to check. Canonicalization also stops catching id-allocation bugs by equality — assert id monotonicity/uniqueness separately if that matters.
