Four bugs I would have shipped without a parity harness

First published in my LinkedIn newsletter. Mousing has since moved to Go in production.
Quick note: Gust and Corsac will continue, the next couple of posts cover Mousing, my SaaS, which is what I'm in the code for right now. The series threads pick back up as I'm in those codebases again.
Every time you port a serious codebase to a new language, the question is the same:
Did I actually match the original behavior?
Unit tests don't answer it. They prove your new code is internally consistent. They don't prove it behaves like the old code under real-world traffic.
For Mousing, my document-extraction SaaS, originally built on .NET, I'd been carrying around an unanswered version of that question for a while. There was a parked Go/Lambda rewrite branch from earlier in the year. It was mostly feature-complete and had been sitting there because "Go's libraries are too weak to render documents" felt true. (It wasn't, but that's a different post.)
When I came back to it last week and brought it to functional parity, I needed to know whether "functional parity" actually meant "behaviorally identical."
The cheapest tool that answered that question wasn't a test suite. It was a differential parity harness, a docker-compose stack that stands up the Go API and the .NET API side by side, runs the same requests against both, and diffs the responses.
Built in one afternoon. Found four real bugs in the next afternoon. Three of them weren't going to show up in any unit test I'd have written.
This post is about what made the harness cheap, what it caught, and the part that surprised me: a parity diff isn't just a "make them match" tool. It's a product-decision tool.
What made it cheap to build
The whole point was building it fast, slow tools never get used.
Three design choices made it work:
Compare at the HTTP layer, never at the database. The .NET stack owns its schema one way; the Go stack uses raw SQL another way. They've deliberately diverged in a few places. Comparing at the database means fighting that divergence forever. Comparing at the HTTP layer treats both APIs as black boxes, what goes in, what comes out, do they match? Everything else is implementation detail you're not allowed to care about.
One API key authenticates both stacks. Both APIs hash incoming keys the same way (HMAC-SHA256, lowercase hex). Same secret, same hash, same Authorization: Bearer msk_... header works against either stack. Seed the identical row into both databases, done. No shared auth service, no JWT plumbing, no test-environment fiddling. This was the single biggest enabler.
A diff tool that masks the volatile stuff. IDs, timestamps, request tokens, these change every run and would create false positives forever. The diff masks them out, normalizes line endings (CRLF in C# string literals vs LF in Go embeds will bite you), and lets you allowlist intentional differences while still failing when something regresses.
The whole thing runs as bash parity/run.sh. About a dozen tests pass deterministically; eight fail by design and are documented as such. The expensive tier, real upload, real OpenAI call, real extraction, real comparison, runs only when you opt in.
The bugs it caught
These are the ones I want to be specific about, because the failure modes weren't subtle:
1. The OpenAI version bug. The Go OpenAI client was sending max_tokens in the request. Fine on older models. gpt-5.x rejects it, returns a 400 with "use max_completion_tokens instead." Every extraction in the Go stack was silently failing.
Unit tests pass. Mock OpenAI passes. The bug only exists when the live model gets the live request. If I'd migrated production traffic to Go without running the live tier of this harness, every customer's extraction would have broken.
This one was the single most valuable find. There's no way to test for it short of actually making the call.
2. Pagination envelope drift. The Go list responses returned the right data but were missing the hasNextPage / hasPreviousPage / totalPages fields in the envelope. Frontend would have rendered broken pagination UI. Fixed in one place, the shared response wrapper, without touching any endpoint code.
3. /user/me ignored API-key authentication. When you call /user/me with an API key (not a JWT), the Go stack only looked for a JWT subject claim, found nothing, and resolved to a null user. The .NET stack correctly resolves to the user who created the API key. Fixed by routing API-key auth through the same user-resolution path the .NET stack already used.
4. .NET needs Redis. Go doesn't. The .NET extraction enqueue uses a real-time messaging backplane that talks to Redis. The harness initially had no Redis service. .NET returned 500 on every extraction request. Adding Redis to docker-compose fixed it. Go uses no Redis for the same operation, a quiet architectural difference the diff surfaced.
Four bugs. Three are runtime issues that compile and test green. Without a live, side-by-side comparison, every one of them would have shipped.
The product-decision surprise
Here's the part I didn't expect.
When the harness fails on a diff, the question isn't always "make them match." Sometimes the question is "which one is right?"
Specifically: the /tenant/settings endpoint was exposing defaultAIProvider, hasOpenAIKey, hasAnthropicKey. The .NET stack returned them; the Go stack didn't. Parity says match, drop them on the .NET side or add them to the Go side.
I looked at it and realized the AI provider is supposed to be an internal implementation detail. The frontend shouldn't be making decisions based on which provider is in use. Exposing it was a leftover from earlier exploration where I thought users would care about that.
The diff didn't just surface a bug. It surfaced a product question I'd been carrying without noticing.
The right answer was to drop the fields on both sides. Make the API more correct, not just consistent.
A parity harness ends up being a forcing function. Every diff is a chance to ask "is this the way it should be?" not just "is this the way it is?"
Where this leaves Mousing
The Go rewrite isn't deployed. The harness proves the two stacks now behave identically for the customer-facing API surface. That's a milestone, not a decision.
The real question, whether to ship Go to production or stay on .NET, is about hosting cost and operational complexity, not parity. The harness took that question off the table cleanly. I know exactly where each implementation diverges, what's intentional, and what isn't. The migration call gets made on real grounds instead of on "did I actually match the original?" anxiety.
If you're building a SaaS and considering a rewrite, the technique generalizes:
Compare at the HTTP layer, not the database.
Hash auth the same way on both sides so one credential works for both.
Mask the volatile fields. Allow intentional diffs. Fail on regressions.
Build the harness before you commit to the migration, not after.
The bugs you find won't be the ones you expected.
That's the point.
Next post: the two beliefs that parked this Go spike for half a year in the first place, and why both turned out to be wrong.





