Precheck started its life as a Python project. A planner written in Python. Providers written in Python. Contracts defined as Python dataclasses. Fixtures on disk. Shell scripts wrapping everything together. It worked. It was the version I demoed to people when I was trying to explain what constraint-aware planning meant. It was also the version that showed me what the system was actually trying to be, which turned out to be something the Python stack was not well-suited to hold.
The port to .NET took roughly two weeks of elapsed time, spread across a few focused sessions. At the end of it, the entire execution engine was in C#, the runtime truth had moved from disk to PostgreSQL, and the dev loop was "one command, one live API, one database." The language change gets most of the attention when I describe this to people. The language change is the least interesting part.
This post is about the interesting part. It is about what happens when you realize, halfway through a migration, that the migration is not actually about the thing you thought it was about. It is about trust models. And the only reason I recognized the abstraction is because I had built the wrong version first.
What "trust model" means here
Every system has to answer the question: when two pieces of the system disagree, which one do you believe? In the Python version of Precheck, the answer was complicated. Sometimes truth lived on disk — the canonical run archive was a JSON file written at the end of a run, and if you wanted to know what had happened, you opened that file. Sometimes truth lived in memory — the planner held state during a run, and the disk artifact was written after the fact from that in-memory state. Sometimes truth lived in the provider's response — the guardrail evaluation was whatever the LLM said it was, and the planner recorded that verbatim. Sometimes truth lived in text parity — two runs were considered equivalent if their rendered outputs matched byte-for-byte, regardless of whether the underlying decisions were the same.
The system had multiple sources of truth that sometimes agreed and sometimes did not. When they disagreed, the system became hard to reason about. A bug in the planner could produce an in-memory state that got written to disk as an archive that rendered as output that passed a parity check against a prior run — and nothing in that chain would catch the discrepancy, because no single layer was the authority.
The migration was my attempt to fix that. But I did not know that was what I was doing when I started. I thought I was porting Python to .NET for type safety and performance.
What the retrospective says
After the migration landed, the agent that did the work wrote the retrospective. This is the opening paragraph of that document, and it is the cleanest statement of what actually happened that I have.
Read those five bullets slowly. Each one is a decision about where authority lives. Semantic parity instead of textual parity — the authority is the meaning of the decisions, not the bytes of the output. PostgreSQL instead of disk — the authority is a transactional store, not a file that might be half-written or overwritten. ExecutionScope instead of ambient state — the authority is an explicit object that carries immutable context and mutable state at the boundary, not a collection of variables floating around in module scope. PlannerResult instead of inferred replay data — the authority is a typed record that can be fed directly to the replay system, not a structure that has to be reconstructed from logs.
None of those are language decisions. You could make every one of them in Python. I know because I tried. What the .NET migration did was force me to commit to them, because the type system would not let me fudge. In Python, a "canonical run archive" can be a dictionary with loosely-specified keys that sometimes contain strings and sometimes contain nested dictionaries and sometimes contain None because the writer skipped a field. In C#, a canonical run archive is a record type with required properties, and if you forget to set one, the compiler tells you, and if you try to read one that was written by an old version, the deserializer fails loudly. The type system is a forcing function for the trust model you claimed to have.
The part I tried to avoid
When I first planned the migration, I was trying to do a direct translation. Python to C#, line by line. Preserve the existing shape. Keep the same file layout. Keep the same execution paths. The working assumption was that the Python version was correct and the .NET version just needed to produce identical output.
That assumption is captured in the plan's revision notes, which are the receipts for how many times I had to correct course before the migration could actually work.
Revision three. Three rounds of corrections to the migration plan before the migration plan was even something I could execute against. The first version was "port the Python to C#." The second was "port the Python to C# but also clean up some of the state management." The third — which is the one that actually shipped — was "change the trust model and let the language port fall out as a consequence."
Every revision was driven by a specific blocker that the prior revision could not handle. Revision one failed because byte-for-byte parity is impossible across languages with different JSON serializers, different default string encodings, and different numeric precision. Revision two failed because the mutable state risks were deeply entangled with the Python patterns I was copying. Revision three worked because it stopped trying to preserve the old shape and instead defined a new shape that the ported code had to fit into.
Direct translations of systems with implicit trust models into languages with explicit type systems produce systems that are worse than either original. The type system will expose every place the old system was relying on coincidence, and you will spend your time fighting the type system instead of benefiting from it — unless you first change the trust model the system is supposed to encode.
What actually slowed the work down
Another section of the retrospective is worth quoting because it identifies what the real expensive blocker was — and it was not what I expected.
The most expensive single blocker in a two-week migration was not a business-logic port. It was a build tooling issue. The .NET project graph was in an intermediate state — some projects had been restored, others had not, and the CLI's summary output was misleading about which was which. The fix was to stop trusting the tool's summary and start trusting the generated artifacts (project.assets.json) as the source of truth about what had actually been restored.
This is a small version of the larger lesson. The summary output is a projection. The generated artifacts are the actual state. When the projection and the actual state disagree, believe the state. That is the same lesson as the trust-model shift, applied to a tool instead of a product. I did not notice the connection at the time. I noticed it writing this retrospective.
Parallel to the CLI story
There is a line in the parent article I want to come back to. The Mech Suit Methodology describes a moment in Phase 3 where I built a PowerShell CLI bridge without knowing CLI tools for AI development already existed, and then discovered the genre by accident after I had solved the problem myself. The lesson I drew from that episode was: "I understood the problem before I discovered the solution, and that ordering makes a real difference in how deeply you understand both."
This migration is the same lesson at a larger scale. I built the Python version first. It worked. It also had a trust model I did not understand at the time, because it was an emergent property of the choices I was making rather than a designed property I was committing to. The reason I could recognize the right trust model for the .NET version was that I had lived inside the wrong one long enough to feel its specific failures. "Sometimes truth lives on disk, sometimes in memory, sometimes in the provider's response" was not an abstract claim I could have reasoned my way to. It was a concrete frustration I had earned, run by run, for months.
If I had started with .NET, I would have built a worse version of .NET. The type system would have caught some of the trust-model problems, but not all of them, because the problems are not primarily about types — they are about what the system believes is authoritative. I would have picked the wrong authority and encoded it in the type system, and then I would have had to unwind it later, except harder because it was now load-bearing.
Building the wrong version first was how I learned what the right version needed to be. Not as a general principle. As a specific property of this system. The Python version was the stress-test for the trust model, and the trust model failed the stress test, and the .NET version is what the corrective looks like.
"Build it wrong first" is not about humility. It is about the fact that some architectural decisions cannot be made correctly until you have felt the consequences of getting them wrong. You can read every migration retrospective ever written and still not know which trust model your specific system needs — because the right answer depends on the specific frustrations your specific implementation produces, and those frustrations only exist after you have built something.
What made this faster
The retrospective has a section titled "What made this faster" and it is worth quoting because it is the cleanest statement of what a good dev loop actually contains.
Read that list. Nothing on it is exotic. A smoke test that covers happy and unhappy paths. One host, one URL. Typed boundaries. Documentation written while the work is hot, not after. Separate docs for different workflows so they do not contaminate each other. Explanations inside the UI so the operator understands what they are looking at.
Every one of those is the same discipline applied in a slightly different place: reduce the number of things the operator has to hold in their head. The smoke test tells you the system works with one command. The single host removes the question of "which port is what." The typed boundaries remove the question of "what shape is this data." The current-state doc removes the question of "what's happening right now." The separated docs remove the question of "which guide applies to my situation." The in-UI explanation removes the question of "what does this screen mean."
None of those are about .NET. All of them would have been valuable in Python. I just didn't do them in Python, because the Python version grew organically and I never stopped to impose the discipline. The migration was an opportunity to impose the discipline while rewriting the code, which is why the two weeks felt productive far beyond what you would expect from a straight port.
The closing line
The retrospective's closing paragraph is two sentences. I am going to quote them and then stop, because they say the thing the post is about better than I can in my own words.
Back to the arc
The parent article's third closing lesson is "build it wrong first." This post is the long-form receipt for that lesson at the scale of a whole-system migration. The PowerShell CLI bridge was the small version. This was the big one. Next up in the series is the post that explains what the new trust model actually is — the philosophical commitment to determinism as a design requirement, and what Precheck gives up and gains from holding that commitment.