Skip to content
Aug 14, 2026·8 min read

When does legacy system acceptance mean anything?

Legacy system acceptance needs production replay, cent-level reconciliation, tested rollback, and handover evidence your engineers can rerun.

When does legacy system acceptance mean anything?

Acceptance is not a meeting, a signature, or a quiet week after launch. A rewritten system is acceptable when the buyer can show that it preserves the behavior the business relies on, accounts for every financial difference, survives a rehearsed reversal, and can be operated without the rewrite team in the room.

That standard sounds severe until a rewrite fails. Then the missing evidence appears all at once: nobody knows whether a changed result is a fixed defect or a regression, totals differ by a few cents across thousands of records, the rollback page describes commands nobody has run, and the receiving engineers discover that the new service has an undocumented dependency on its makers. Acceptance should expose those facts while the project can still act on them.

Acceptance starts with a contract for evidence

Define acceptance before implementation begins, because the team that writes the new system must know which claims it will have to prove. A requirement such as "works like the old system" cannot be tested. Replace it with observable rules, named data sets, tolerances, responsible approvers, and evidence that will remain after the decision.

The contract should separate four decisions that organizations often collapse into one. Functional parity asks whether the same valid request produces the same business outcome. Financial reconciliation asks whether postings, balances, taxes, rounding, and allocations agree at the level the books require. Operational acceptance asks whether the system can be deployed, monitored, restored, and reversed by the people who will run it. Ownership acceptance asks whether those people can change it safely after the rewrite team leaves.

Each decision needs a named owner. Product or operations can approve intentional behavior changes. Finance controls financial tolerances and signs the reconciliation. The service owner accepts operational procedures and residual risks. Security reviews changed trust boundaries and access paths. A steering group may receive these decisions, but it cannot make them by averaging opinions.

Write the contract as a small matrix, not a large prose document. For every acceptance claim, record the evidence source, pass rule, exception process, approver, and retention location. "99 percent of cases match" is not a pass rule unless the contract explains which one percent may differ. A single mismatch in a regulatory report can matter more than ten thousand harmless differences in whitespace.

Freeze the acceptance contract through ordinary change control. A discovered edge case may justify a new rule, but the team must not loosen a tolerance merely because the rewrite misses it. Record who changed the rule, why, and which prior test results became invalid. Otherwise the acceptance target moves whenever the implementation gets uncomfortable.

Define the acceptance environment with the same care. Record operating system images, database extensions, locale data, time-zone database, message broker settings, feature flags, and reference data. A test result produced under an unnamed environment cannot be reproduced later. Do not accept "production-like" as a description. Give the environment an identifier and include its configuration digest in every evidence bundle.

Performance belongs in the contract when time changes business behavior. Specify workloads, concurrency, data volume, warm-up, and percentile rules at the actual boundary. A nightly job that returns correct totals after the morning downstream cutoff has failed. So has an interactive path whose slow response causes callers to retry and create duplicate work. Keep performance evidence separate from functional parity, since one cannot excuse the other.

The contract also needs an explicit non-goal: the new architecture does not have to resemble the old one. Behavioral parity and implementation parity are different claims. Reproducing a COBOL batch layout inside a Go service may preserve accidental structure while making the new system harder to own. Accept observable behavior at stable boundaries, then judge the new internals by current engineering standards.

Recorded production traffic is evidence, not a complete test suite

Recorded production traffic gives the rewrite team the best available sample of what callers actually do, but it proves only the behavior present in the recording window. Treat replay as a large characterization corpus, then add tests for rare, destructive, seasonal, and legally sensitive paths that the sample missed.

Michael Feathers introduced characterization tests in Working Effectively with Legacy Code as tests that describe what a system currently does. That idea fits a rewrite well because the old implementation is often the only precise specification left. I would qualify it in one important way: recorded output becomes evidence of current behavior, not an automatic statement that the behavior is correct.

Capture requests at a boundary where they have business meaning. An HTTP gateway may work for a web monolith. A message topic, batch input, terminal transaction, file drop, stored procedure call, or job control invocation may be the real boundary elsewhere. Capture enough context to reproduce routing, authorization class, locale, effective business date, and relevant feature state. Do not collect secrets simply because they are available. Tokenize account identifiers, remove credentials, and keep a controlled mapping only when the replay needs stable identity across calls.

Every captured case needs an immutable case ID and provenance. At minimum, retain the source system version, capture time, request shape, selected state snapshot, expected observable outputs, and the sanitization transform. Hash the original capture and the normalized fixture. That lets a reviewer tell whether a test changed or the system changed.

A useful fixture envelope looks like this:

{
  "case_id": "close-004812",
  "captured_at": "2026-03-31T23:58:14Z",
  "business_date": "2026-03-31",
  "request": {"operation": "post_invoice", "invoice_ref": "TKN-8821"},
  "state_snapshot": "sha256:7d3f...",
  "expected": {
    "status": "posted",
    "ledger_delta_minor": 18425,
    "events": ["invoice.posted"]
  }
}

The fixture stores money in minor units because binary floating point is a poor acceptance boundary. It names the event but does not require an identical message ID or timestamp. Those volatile fields belong in the comparison policy.

Production samples have predictable blind spots. Month-end and year-end processing may not occur in the window. Failure paths may be suppressed by retries. Administrators may perform rare corrections through a separate interface. A capture may contain no leap-day date, empty file, maximum field length, duplicate message, reversal, timeout after commit, or partially written batch. Add cases from operating procedures, incident notes, support tickets, and schema constraints. Ask the people who close the books which inputs make them nervous. They usually know where the code lies.

Replay must not repeat external side effects. Route emails, payments, file transfers, and downstream messages to deterministic fakes or isolated test endpoints. If a request cannot be made harmless, compare it in a shadow path that blocks commits. A replay harness that can bill a customer is not an acceptance tool.

Parity needs an explicit comparison policy

Two outputs rarely deserve a raw byte comparison, so define which differences carry business meaning before running the corpus. The policy should normalize volatile fields, compare significant fields exactly, and classify every remaining mismatch without hiding it.

Start with the full observable result: response, database changes, emitted messages, files, logs required for audit, and externally visible timing behavior. A rewrite can return the same JSON while omitting a ledger posting. It can write the right rows but emit events in an order that breaks a consumer. Comparing only the easiest surface creates false confidence.

Normalization must be narrow and reviewable. Replace generated IDs only if identity itself has no downstream meaning. Convert timestamps to an agreed precision only if subsecond order is irrelevant. Sort collections only when the contract says order is not observable. Never delete a field from comparison because it changes often. First decide whether callers depend on it, then record the normalization rule.

The harness should produce a result that a reviewer can inspect without reading its source code:

{
  "case_id": "close-004812",
  "result": "FAIL",
  "comparisons": [
    {"path": "$.status", "expected": "posted", "actual": "posted", "rule": "exact"},
    {"path": "$.ledger_delta_minor", "expected": 18425, "actual": 18424, "rule": "exact"},
    {"path": "$.processed_at", "expected": "<timestamp>", "actual": "<timestamp>", "rule": "normalized"}
  ]
}

Keep four outcomes: pass, expected change, known defect, and unexplained mismatch. "Close enough" is not an outcome. An expected change points to an approved decision record and its new test. A known defect points to an owner and disposition. An unexplained mismatch blocks acceptance for that claim.

Measure coverage by business dimensions rather than case count. Label cases by operation, account or customer class, input channel, authorization path, currency, date boundary, error class, and side-effect type where those dimensions apply. Then report empty intersections. Ten thousand invoice queries do not compensate for zero credit reversals.

Run the old and new systems against controlled initial state. If both runs share mutable state, the first run may change the conditions for the second. Restore a snapshot or run isolated copies. Freeze clocks, random seeds, exchange-rate tables, and configuration where possible. When the old system reads a mutable external source, capture the response as part of the case.

Do not use the new implementation to generate its own expected results. I have seen teams replay traffic through the rewrite, approve the output by eye, and save it as the baseline. That checks repeatability, not parity. The old system, an independently approved business rule, or a reconciled book must supply the oracle.

Reconcile money at the posting boundary

Financial acceptance requires exact agreement at the accounting boundary, plus a documented explanation for every intentional difference. Aggregate totals alone cannot reveal offsetting errors, duplicate postings, wrong dates, or correct amounts assigned to the wrong accounts.

Compare money in the representation used by the business rule. If the source stores integer cents, compare integer cents. If it uses fixed decimal values with currency-specific scales, preserve those scales through the harness. Do not route acceptance calculations through spreadsheets that silently coerce types or display rounded values while retaining different numbers underneath.

Reconcile in layers. First compare transaction counts and unique business identifiers. Then compare each posting line by entity, account, currency, effective date, debit or credit direction, and amount. After line-level agreement, compare control totals by the same dimensions finance uses. Finally compare resulting balances and reports. Each layer catches a different failure and gives the investigator a smaller search area.

An anti-join exposes missing and extra postings, while a grouped comparison exposes amount differences. Adapt this Postgres query to the actual business identity rather than inventing a synthetic row number:

WITH old_totals AS (
  SELECT entity_id, account_code, currency, effective_date,
         SUM(amount_minor) AS amount_minor, COUNT(*) AS line_count
  FROM old_postings
  GROUP BY entity_id, account_code, currency, effective_date
),
new_totals AS (
  SELECT entity_id, account_code, currency, effective_date,
         SUM(amount_minor) AS amount_minor, COUNT(*) AS line_count
  FROM new_postings
  GROUP BY entity_id, account_code, currency, effective_date
)
SELECT COALESCE(o.entity_id, n.entity_id) AS entity_id,
       COALESCE(o.account_code, n.account_code) AS account_code,
       COALESCE(o.currency, n.currency) AS currency,
       COALESCE(o.effective_date, n.effective_date) AS effective_date,
       o.amount_minor AS old_amount, n.amount_minor AS new_amount,
       o.line_count AS old_lines, n.line_count AS new_lines
FROM old_totals o
FULL OUTER JOIN new_totals n USING (entity_id, account_code, currency, effective_date)
WHERE o.amount_minor IS DISTINCT FROM n.amount_minor
   OR o.line_count IS DISTINCT FROM n.line_count;

The required output is zero rows for dimensions declared exact. If finance permits a tolerance for a derived allocation, encode that exception by rule and account class. Never apply a global tolerance. A one-cent difference on every one of a million postings is not a small error, and a zero net difference can conceal pairs posted to the wrong accounts.

Rounding deserves its own cases. Specify whether the rule uses half up, half even, truncation, or a currency-specific method, and at which step rounding occurs. round(sum(x), 2) and sum(round(x, 2)) can disagree. The old program may carry fractions through allocation and distribute the remainder to a designated line. The rewrite must reproduce that outcome unless finance approves a changed policy.

Reconciliation evidence should include the input snapshot identifier, query or program version, row counts, unmatched rows, grouped differences, report totals, runner identity, and approval. Store the actual exception set, not a screenshot of a green summary. Finance must be able to trace a displayed total back to postings without asking the migration team to reconstruct the run.

Parity does not grant every old defect citizenship

Keep regulated code inside
Air-gapped models can run on hardware inside your perimeter for regulated environments.

An old result that surprises the business needs classification, not automatic preservation. The acceptance process should distinguish required behavior, tolerated behavior, accidental behavior, and forbidden behavior, then attach a decision to each departure.

Required behavior includes contractual calculations, formats consumed by other systems, approved workflows, and controls. Tolerated behavior may be odd but relied on, such as accepting a legacy identifier with leading spaces. Accidental behavior has no known consumer and conflicts with the intended rule. Forbidden behavior violates a current policy or creates an unacceptable security, legal, or accounting risk.

Teams often say a rewrite is the chance to clean up everything. The recommendation is popular because defects are visible and the new code seems easy to change. It is also a poor acceptance strategy. Combining behavior changes with a platform rewrite makes every mismatch ambiguous and expands the rollback problem. Preserve required behavior, isolate approved corrections behind explicit decisions, and defer unrelated cleanup until the new system has a stable operational baseline.

For each intentional change, retain the old case, mark its old output, cite the approving owner, and add the expected new output. The parity report can then show "expected change" instead of pretending the case passed. Downstream owners must confirm that they can accept the changed contract. A corrected tax calculation is still a breaking change if a report importer expects the old fields.

Security corrections sometimes cannot wait. If the old system exposes data to an unauthorized role or accepts unsafe input, do not reproduce that merely to turn the report green. Record the difference as a required control change, test both the denial and the permitted path, and ensure rollback does not reintroduce the exposure without an explicit risk decision.

This classification also prevents quiet transliteration. A team can preserve externally required behavior while replacing batch staging tables with durable queues, splitting a monolith by business responsibility, or moving calculations into a Rust numeric kernel. Acceptance should observe stable contracts and effects. It should not force the new design to copy dead structures that no caller can see.

The decision log must stay small enough to read. One row per meaningful behavior difference is better than a document per test case. Include the case IDs affected, old behavior, new behavior, reason, approver, downstream impact, rollback treatment, and date. If hundreds of unexplained entries accumulate, the rewrite has not reached acceptance merely because somebody renamed them "known differences."

A rollback rehearsal must change real state

Finish while evidence is current
Every CodeHero rewrite is delivered in under 30 days, before traffic fixtures become stale history.

A rollback is credible only after the operating team has executed it against a production-like deployment, restored authoritative state, and proved that processing can resume without loss or duplication. Reading a runbook aloud does not exercise permissions, artifacts, clocks, queues, or human coordination.

Choose the rollback unit before launch. It may be a full service, a traffic slice, a customer cohort, a job family, or a read path. Define the latest safe decision point and the state transition that occurs when traffic returns. The harder question is data: once the new system accepts writes, can the old system read them, can they be replayed, or must rollout stop before any incompatible write?

Dual writing is often proposed as insurance. It can help, but it creates a third behavior to test: how the systems recover when one write succeeds and the other fails. If ordering, retry, and idempotency rules are unclear, dual writing increases ambiguity during an incident. A durable change journal with a tested replay path is often easier to reason about than two synchronous commits pretending to be one transaction.

Run the rehearsal with the same identities and controls used in production. The on-call engineer, not the author of the deployment automation, should initiate it. Security staff should not grant temporary broad access simply to make the exercise pass. Fetch the pinned prior artifact from the real registry, apply the actual routing change, restore or replay state, and run smoke transactions through the recovered path.

A useful rehearsal record contains five facts:

  1. The trigger and the person authorized to call rollback.
  2. The exact deployed and restored artifact identifiers.
  3. The last confirmed transaction before cutover and the first after recovery.
  4. The reconciliation result for the transition interval.
  5. The measured recovery time and any manual intervention.

Inject one realistic complication. Leave a message in flight, delay a dependent service, rotate an operator shift, or make the new schema contain a write that needs reversal. The aim is not theatre. The complication tests the boundary where tidy runbooks usually fail.

After traffic returns, prove more than endpoint health. Compare queue depth, scheduled jobs, control totals, authorization outcomes, and downstream acknowledgements. Confirm that retries did not duplicate work and that caches do not keep serving new-system data into the restored path. Keep the evidence bundle with timestamps from the systems involved, not a reconstructed narrative written later.

If full rollback is impossible after a migration point, call the plan what it is: roll-forward recovery. Define how the team disables the faulty path, repairs state, and deploys a correction. Approval can still be rational, but nobody should sign a claim that the system can roll back when the data model makes that false.

Handover proves ownership through independent action

Engineers own the rewritten system when they can explain, operate, diagnose, change, deploy, and recover it without privileged help from its authors. A folder of documents is input to handover, not proof that handover succeeded.

The receiving team should perform the acceptance tasks. Have them trace one production request across service, queue, database, and emitted event. Have them diagnose a seeded failure using the supplied telemetry. Ask them to change a small business rule, update its test, pass the build, deploy to a controlled environment, and revert the change. Then let them execute the rollback rehearsal and reconcile the affected transactions.

These exercises expose missing knowledge quickly. If the team cannot explain why a parity normalization exists, the comparison policy is not owned. If only the rewrite vendor can regenerate a client or rotate a signing secret, the build is not owned. If deployment needs an account absent from the access inventory, operations are not owned.

The handover pack should contain artifacts that engineers can run and review:

  • Source repositories with build files, dependency locks, generated-code instructions, and ownership rules.
  • Architecture decisions, interface contracts, data models, threat assumptions, and the behavior difference log.
  • Deployment, rollback, backup, restore, migration, reconciliation, and incident runbooks with exact commands.
  • Dashboards, alert definitions, service objectives, log field definitions, and known diagnostic queries.
  • Access inventory, secret rotation procedures, license inventory, support boundaries, and unresolved risks.

Pin tool versions and verify a clean build on an environment controlled by the receiving organization. Archive required compilers and generators where licensing permits. A build that succeeds only on an author's workstation is unfinished. The team should also know which generated artifacts belong in source control and which must be recreated, including the command and expected output path.

CodeHero ties behavior to the original through a parity harness against recorded production traffic while modernizing the architecture, and that harness should become part of the customer's permanent regression suite rather than a disposable project prop. For regulated environments, an air-gapped installation inside the customer perimeter changes the handover inventory: model artifacts, hardware ownership, update procedure, access controls, and evidence export all need named owners.

Ownership has a commercial edge too. List every dependency on the rewrite team: hosted build service, private package, license, deployment credential, alert destination, model artifact, or undocumented approval. Remove it, transfer it, or accept it explicitly. "Call us if it breaks" can be a support arrangement, but it cannot substitute for engineering ownership.

Finish handover with a reverse briefing. The receiving engineers explain the architecture, highest operational risks, rollback boundary, parity exceptions, and first incident actions to the rewrite team. Misunderstandings surface when the new owners have to teach the system back. Record gaps as acceptance actions with owners and dates.

Sign-off should be a reproducible decision

Keep the parity harness
Your regression suite keeps the same production cases used to accept the rewrite.

Final sign-off should identify the exact release, evidence set, accepted exceptions, residual risks, and people making each decision. Someone who was absent should be able to reconstruct why the organization accepted the system and rerun the material checks.

Create an immutable acceptance index. It points to the source and target commit identifiers, deployment artifact digests, fixture corpus version, comparison policy version, parity report, reconciliation package, security review, performance evidence, rollback rehearsal record, handover results, decision log, and open risk register. Sign or hash the index and store it under the organization's retention policy.

Do not let a single percentage dominate the decision. Report results by acceptance claim and business dimension. State how many cases passed, changed by approval, exposed a known defect, or remain unexplained, but attach the actual case set. For financial controls, include exact unmatched amounts and rows. For operations, include the rehearsal result and any deviation. For ownership, include the tasks the receiving team performed and the gaps it found.

Residual risk belongs in the acceptance record, not in meeting minutes. Describe the condition, impact, detection method, containment, owner, and expiry or review point. An accepted risk has an accountable person and an operating response. An unassigned caveat is unfinished work.

Set expiry conditions for evidence that can go stale. A new release, changed normalization rule, modified schema, new production channel, or revised accounting policy may invalidate part of acceptance. The index should say which checks must run again. Acceptance applies to an identified system under identified conditions, not to every future version that shares its name.

Keep a narrow launch gate. No unexplained mismatch may affect a required behavior. Financial reconciliation must meet the rules finance approved. The rollback or roll-forward path must have been executed. The receiving team must complete the ownership exercises. Any exception needs an owner who has authority to accept its consequence.

I would reject a rewrite with beautiful code and weak evidence. I would accept one with a short, explicit defect list if the required behaviors match, the books reconcile, recovery works, and the engineers who carry the pager can change the system themselves. The signature then records a decision already proved in machinery, accounts, and human practice.

FAQ

What

should

Is

recorded

How

much

Should

a

How

do

What

is

How

do

What

if

Which

handover

Who

should