Index

Building a Rating Engine

The Migration Plan Was Solving the Wrong Problem

The approved migration plan called for a fresh database schema. I wanted to keep the existing one. That disagreement wasn’t hidden in a difficult Go implementation; it was written into the planning package before the production port phase began.

I had approved that package for planning, not implementation. When I asked whether the existing migrations and schema would be faithfully replicated, the answer was no: the documents explicitly described a redesign. The plan could be internally consistent and still be the wrong plan for this engine.

This was one of the more useful lessons from doing the migration with AI. Reviewing generated code wasn’t enough. I also had to review which application the agent believed it was supposed to build, what it was allowed to change, and which differences actually mattered. Later, “preserve everything” needed another correction so the work didn’t expand into reproducing PHP itself.

The database wasn’t the part I wanted to replace

The old persistence strategy said:

redesign from business ownership, invariants, query shapes, scale, retention, and recovery; do not mechanically port Laravel tables

Its migration model was a fresh target schema with optional conversion of development fixtures. There was no deployed customer database to migrate, so that strategy didn’t introduce a live-data migration problem. It did introduce a different engineering project.

The existing schema had taken months of design, testing and database-level tuning. The table-and-row redesign and subsequent preparation, indexing and payload work were deliberate parts of the PHP implementation. This wasn’t a collection of accidental Laravel tables which happened to contain the business data.

The reason for moving to Go was the remaining runtime and framework cost, described in the runtime chapter . Rebuilding the schema at the same time would change both the application and the runtime under comparison. Even a faster result would leave another question to answer: how much came from Go, and how much from doing different database work?

A redesign could still have been a sensible separate project. If the schema had been the bottleneck, or if the product needed a different model, preserving it would have been the wrong constraint. Neither “new language” nor “no production data” established that case. The database’s existing performance work was something to retain while replacing the layer above it.

The planning approval was mine. Blaming the agent for following a direction which appeared in an approved package would miss the actual problem. I needed the written target to match the work I wanted to authorize, and I needed to check that match before treating planning completion as implementation readiness.

Correcting one paragraph left other permissions behind

The conflicting direction appeared in the main goal, target-system description, persistence decision and inventory dispositions. Changing one sentence to “faithful port” wouldn’t have removed the other documents' permission to redesign.

The replacement persistence contract made the physical boundary explicit. These are selected fields from that contract, with the longer preservation and verification lists shortened:

{
  "preserve": [
    "tables and columns",
    "types, precision, nullability, defaults, and generated values",
    "indexes, predicates, expressions, operator classes, and ordering"
  ],
  "verification": [
    "migration-by-migration structural mapping",
    "catalog fingerprint comparison",
    "query and query-plan parity on canonical fixtures"
  ]
}

The full contract also covered constraints, transactions, isolation, locking, failure and benchmark-sensitive access paths. A new package layout in Go couldn’t silently become a new persistence model. Bugs or potential improvements could be recorded for later work; finding one wasn’t permission to correct the PHP behavior inside this port.

Generated inventory records needed the same correction. The actual policy normalizer included mappings such as:

DISPOSITIONS = {
    "redesign": "preserve-exact",
    "replace": "replace-php-runtime",
    "retire-ui": "retire-ui",
    # Other mappings omitted.
}

That changed the authority attached to a planned item. It didn’t port a query, run a migration or make a Go test pass. A corrected inventory could describe the right work while all of that implementation work still remained. It would be absurd to count a renamed disposition as newly achieved compatibility, however neatly the resulting document validated.

The UI boundary needed more care than a blanket exclusion too. I wanted to retire the browser presentation but keep the internal tester’s ability to execute premade configurations. That became an explicit CLI exception. The tester chapter follows the program which survived: its scenarios, state, HTTP calls and stored runs. “No UI” alone didn’t describe that requirement well enough.

These weren’t competing architectural opinions to leave open for the agent. They were choices about what product was being ported. The migration could use different runtime mechanics, but it wasn’t free to choose a different database or drop a useful execution capability because either choice made the implementation simpler.

A timestamp made the compatibility question concrete

The opposite mistake would be to dismiss every awkward detail as irrelevant PHP behavior. One result factory shows why that would be too convenient. It built the stored outcome for publishing a carrier configuration version. Its PHPDoc said the publication timestamp was normalized to UTC. Its code did this instead:

'published_at' => CarbonImmutable::instance($publishedAt)
    ->toIso8601String(),

The conversion retained the supplied offset and formatted whole seconds. With the synthetic factory input 2026-03-16T09:10:11.123456+05:30, direct execution gave:

PHP factory:  2026-03-16T09:10:11+05:30
UTC version:  2026-03-16T03:40:11+00:00

Those strings represent the same instant. They aren’t the same stored outcome. The publication executor passed the factory’s array into idempotency completion, both as the outcome reference and as canonical JSON response material. Normalizing the offset because the comment said “UTC” would change that application value. The nullable supersede-event field also remained present as null; it wasn’t omitted just because no earlier version had been superseded.

The source test’s ordinary example used UTC, so that example alone wouldn’t distinguish offset preservation from UTC conversion. The implementation and the additional native factory probe exposed the difference. This was a factory-boundary observation, not proof that every HTTP route accepted every possible date representation or that the whole publish workflow had been executed.

Go didn’t need to become PHP to preserve the outcome. The corresponding field used its standard time formatter with an offset-preserving layout:

{
    Key: "published_at",
    Value: at.Format(
        "2006-01-02T15:04:05-07:00",
    ),
},

The focused Go test checked the same offset-bearing input and expected outcome. That is a concrete preservation question with a source, an input and an observable value. It is much more useful than asking whether the Go version is “as faithful as possible” and leaving the scope of faithful unspecified.

It also shows why a comment isn’t a sufficient oracle for an AI translation. Here the comment and executable behavior disagreed. An explanation based on the comment could be perfectly coherent and still lead to the wrong field value. The source operation, its caller and the stored result were what needed to be compared.

Preserving the application didn’t mean implementing PHP

Later in the port, I had to draw that boundary from the other side. Discussion had expanded into PHP object serialization, JSON parsing and sorting mechanics. I questioned whether all of those differences affected supported inputs, outputs, stored state or business behavior—or whether we were making Go reproduce PHP internals without an application reason.

That was a scope correction, not permission to drop inconvenient failures or decide that the final price was the only observable result. The history-append mismatch already shows a real persistence difference hidden behind an otherwise successful operation. The publication timestamp above is another application value worth checking. Conversely, a synthetic demonstration that two runtimes behave differently doesn’t automatically establish a migration requirement.

The amended goal required supported application inputs and public outputs, meaningful ordering, persistence, workflows, failures, retries and idempotency to remain equivalent. PHP parsing, serialization, object mechanics, sorting algorithms and intermediate representations weren’t independent targets. Reproducing one of those details required a concrete reachable application path which depended on it.

For the timestamp, the relevant path was the factory’s accepted date object, the formatted outcome field and the executor which stored it. That doesn’t automatically extend the requirement to every arbitrary date string a PHP date parser might understand. Broader replay inputs need their own supported producer or contract. Otherwise one useful compatibility check can turn into an open-ended interpreter project.

This also changed what I wanted from a bounded agent task. A task such as “port this factory” could produce a reasonable-looking translation of its comments. A better task named the executable source, the outcome fields and the fresh-versus-replay distinction, then required comparisons at those boundaries. For a database command, that also included its stored effects and failure behavior. The amount of text in the instruction wasn’t the important part; the ability to reject the implementation on a specific application difference was.

The agent could do a lot of that work: locate callers, expand source datasets, generate target tests, run focused comparisons and expose disagreements. The test-case chapter shows how the existing suite became explicit questions rather than a vague promise to keep coverage high. That tooling helped make a large port inspectable. It didn’t decide whether a fresh schema belonged in scope or whether a new runtime curiosity deserved another compatibility layer.

Neither correction—the removal of redesign authority nor the later application-parity amendment—was evidence that every remaining implementation had converged. They changed which work was justified and what a completion claim had to establish. I still needed to inspect the code and the results.

For this migration, a proposed compatibility fix now needs to point to the request, result or stored record it preserves. A schema improvement needs a separate objective. I can ask the agent to investigate either. Choosing a different application isn’t part of that delegation, and a green check against its plan doesn’t authorize it.