December 2025
Forbid the edit by making it impossible to land, not by telling people not to
A CI gate that regenerates generated files from their spec and fails on any drift can never be satisfied by a hand-edit, and the same trick quietly stops working where the generator's output ends.
A teammate spent a morning fixing a query bug in users.sql.go. He found the wrong column in a join, corrected it, ran the package tests green on his laptop, pushed. The gate went red anyway. His fix was right. The file just happened to end in .sql.go, and the first thing the gate does to any file matching *.gen.go, *.sql.go, or the generated TypeScript client is throw away its contents and write them again from the spec.
Sit with that for a second, because it is stranger than the red build made it look. The gate does not read his diff to find a path it was told to protect. It deletes each generated file, regenerates it from the spec byte for byte, diffs the result against what is on disk, and fails on any difference. A hand-edit to a generated file therefore cannot produce a green build, ever, no matter how correct the edit is, because the edit is exactly the drift the diff exists to catch. (His good fix and a stray typo would have failed in precisely the same way, which he found insulting.)
I had assumed, before I understood the gate, that it worked the way every “do not touch” rule I had met worked: a linter holding a list of protected paths, firing an error when one of them changes. A list like that carries intentions, and intentions can be wrong. A new generated file gets added and the glob never learns about it. Someone makes a persuasive case that their particular edit is harmless. The list drifts out of sync with the repository and nobody notices until something downstream does. Regenerate-and-diff keeps no list and holds no opinion about which edits are acceptable. The generator’s output is the ground truth, and whatever is not that output is drift by construction.
None of this requires a developer to know, per app, which files are generated or how to rebuild them. The same moon task name regenerates in every app, and the spec lives in the same location in each one. One task, one place to look.
The spec is allowed to change, and the way it is allowed to change is the second rule holding this together. Wire payloads are append-only: a field, once it exists in the spec, can never be removed or repurposed, only joined by new ones. So when the generator runs and a service grows a field, a consumer still building against last week’s generated client does not break. It receives a field it never reads and carries on. Regeneration stays safe to run on every push because the thing being regenerated only ever grows.
Here is where I expected the same machine to keep working and watched it stop. There is a rule that the frontend must reach the backend only through the generated typed client, never through a hand-rolled fetch against a URL string. You would think regenerate-and-diff could carry this one too. It will happily regenerate the typed client and fail on a single character of drift in that file; what it cannot do is verify that a component three directories away imports the client instead of writing fetch('/api/users') by hand. That fetch lives in code the generator does not own and never emits. There is no spec to regenerate it from, so there is no ground truth to diff it against. The rule falls to review, to a person reading the diff and pushing back, which is the very mechanism the gate was built to retire.
So the property is sharper than “an invalid state cannot land.” Regenerate-and-diff covers exactly the files the generator produces and goes quiet the moment it reaches anything else. The unrepresentability holds with full force inside that set and ends at its boundary. The typed-client rule sits one step past the boundary, in hand-written call sites, where the only thing between a bypassed client and a green build is somebody noticing in review.