Field Notes · The Precheck Cut
05 / 08

From File-and-Script to Typed Execution

The Python-to-.NET port was not a language migration. It was a change in the trust model — from disk-as-state to database-as-truth. The only reason I recognized the abstraction was because I'd built the wrong version first.

Most migration retrospectives I have read open with a list of what got ported, how long it took, and what broke. This one opens with a sentence that I keep coming back to: "We did not just port code. We moved the system from a file-and-script driven workflow into a typed execution engine backed by PostgreSQL and served through a single live host. The important part was not the language change. It was the change in trust model."

That sentence is the reason this post exists.

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.

Receipt docs/migration-retrospective-2026-03-28.md
We did not just port code. We moved the system from a file-and-script driven workflow into a typed execution engine backed by PostgreSQL and served through a single live host. The important part was not the language change. It was the change in trust model: - semantic parity instead of byte-for-byte mimicry - PostgreSQL instead of disk as the runtime truth - ExecutionScope instead of ambient or reconstructed state - PlannerResult instead of inferred replay data - one fresh local slice instead of many overlapping launch paths That shift made the system less fragile and made the UI more honest.

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.

Receipt plan/engine-port-python-to-dotnet.md
### Revision Notes (rev 3) Three structural corrections applied to the original plan: 1. Parity model — Split into semantic parity (permanent contract) and textual parity (temporary migration aid). The .NET system must produce the same decisions and projections, not the same bytes. 2. Source of truth — PostgreSQL is the canonical store for new runs. Disk artifacts are derived exports, not authoritative state. 3. Execution model — Singleton/mutable state risks resolved. All execution services are stateless or scoped. Providers are instantiated per-run. Loose Dictionary removed from core paths. ExecutionScope encapsulates immutable RunContext, hydrated PersistedState, and ephemeral ExecutionState at the boundary. 4. Replay/validation model — Invalid requests fail before persistence. PostgreSQL is the replay contract for new runs. Disk artifacts are export/debug only.

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.

Limitation Discovered

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.

Receipt docs/migration-retrospective-2026-03-28.md
The most expensive blocker was not business logic. It was the API project graph. We saw restore/build behavior fail before project.assets.json materialized, which looked like a compile issue until we checked the generated state. The fix was to stop treating the solution as a magical unit and instead force a stable graph, one project at a time. That was the moment we learned to trust the filesystem and the generated assets more than the summary output.

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.

Unlock

"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.

Receipt docs/migration-retrospective-2026-03-28.md
The following things genuinely sped us up: - a single smoke test that covered both valid and invalid input - a single runtime host at http://localhost:18100 - typed boundaries instead of generic bags - documenting the current state while the work was still active - keeping compose-based import/runtime docs separate from the fresh slice docs - putting the demo explanation into the UI itself

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.

Receipt docs/migration-retrospective-2026-03-28.md
The migration succeeded because we stopped trying to preserve the old system shape. We kept the useful outcomes, but we changed the truth model and the execution boundary. That is what made the .NET version durable.

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.