← Research log
Report #18 · 2026-07-12

The gates were breaking the files they were repairing

Every spec in the suite, one run each, no retries: 19 of 22 green. That is the headline, and it is not the interesting part. The interesting part is what the three failures turned out to be — because the last of them was not a model failure at all. The fix loop kept dying on a line that no model had written. The gates had written it.

The line nobody wrote

taskapipro's fix loop burned every round it had on this:

tk1, _ := models.Task{ID: "1", Title: "task1", Status: "todo"}

A composite literal yields one value. There is no circumstance in which that line is valid Go, and no model produced it — the artifact it came from had tk1 := models.Task{…}, which is fine. Tracing the gate chain pass by pass:

  1. _requalify_undefined adds two imports to the top of the file.
  2. Every line below them shifts down by two.
  3. _fix_assignment_arity — still holding the line numbers the compiler handed it before the insert — applies its repair to line 40, which is no longer the if err := svc.Create(…) it was aimed at, but the tk1 := … above it.

Almost every gate is indexed by a compiler line number. A gate that inserts a line silently invalidates those numbers for every gate behind it in the same pass. This has been true since the day there were two kinds of gate. It corrupts a file whenever an import is added alongside an in-place repair, and it presents as "the fix loop failed to converge" — which reads, from the outside, exactly like a model that isn't good enough.

The fix, and what the fix taught

The chain now runs in two phases. Phase one is the repairs that rewrite a line in place — safe together, because none of them changes how many lines a file has — and it returns the moment any of them fires, so the caller re-compiles and the next pass gets fresh numbers. Phase two is the line-shifting repairs: an import added, a struct field inserted, a method appended to an interface, a statement hoisted, a file reformatted. One per pass. Nothing runs behind one.

I classified the gates into those two phases by reading them. Then I wrote a property test to pin the classification — phase one must not contain a gate that changes a file's line count — and it failed on its first run, three times over:

GateLooks likeActually
_fix_string_int_conversiona one-line rewritestring(42)strconv.Itoa(42) needs an import
_fix_errors_wrapa one-line rewriteerrors.Wrapfmt.Errorf needs an import
_fix_unknown_struct_fieldsa one-line rewriteinserts a field into a struct

All three look like single-line repairs. That is precisely why reading the code was not good enough, and why the test now reads the phase-one list out of the chain itself: a gate filed in the wrong phase fails there, instead of quietly corrupting a file six months from now.

A second gate bug fell out of the same trace. _fix_assignment_arity, padding a lone err, appended the blank — err, _ := svc.Create(…) — which assigns the Task to err, so the err != nil beside it is a type mismatch. Go puts the error last. The gate was manufacturing the exact defect that _fix_swapped_error_assignment exists to repair: one gate breaking what the next one fixes, and the project only surviving because the second gate happened to run.

With both fixed, the gate chain takes the red taskapipro artifact — the one the sweep gave up on — to green on its own: seven passes, four packages race-clean, no model involved at any point.

The last hard compile class

taskapipro's actual build error had been in my notes for months, labelled "gate candidate, but the physics are different":

return paginate(s.store.ListProjects(ctx), limit, offset), nil
// multiple-value s.store.ListProjects(ctx) (value of type
// ([]models.Project, error)) in single-value context

ListProjects returns (items, error) and the model used the call as an argument, dropping the error on the floor. Go cannot express that. Every other gate rewrites one line; this one has to introduce statements, which is why it was left alone.

What makes it tractable is that the enclosing function's signature settles everything — the error goes to the last result, each earlier result takes its zero value — so the repair is read off the AST rather than invented:

items, err := s.store.ListProjects(ctx)
if err != nil {
        return nil, err
}
return paginate(items, limit, offset), nil

And it refuses whenever the signature does not settle it. The guard that matters is the zero value it cannot prove: models.Project{} looks obvious and is not — if models.Project were an interface, that literal would not compile, and the file cannot see the other package to know. A named type is given a composite literal only when this file declares it a struct. Everything else refuses.

A green build is not the project you asked for

One more, found while reading a successful run. workapi's green artifact contains internal/store/memory.go. It is one line long: package store. The spec asked it to hold the MemStore; the model wrote MemStore in store.go instead, so memory.go had nothing left to declare and shipped empty. Go's compilation unit is the package, not the file — so the build is green, the tests pass, and nothing complains. All four multi-package artifacts carry one of these.

A prompt telling the model to stay in its lane did not stop it. So it gets a repair, and the repair is safe for a reason that is a fact about the language rather than a clever argument: moving a declaration between files of the same package cannot change what the program means. The type, its methods and its constructor move back to the file the plan gave them; each half is given exactly the imports it uses; the whole project is re-checked, and if it is not still green the move is reverted. It was reverted twice while I got it right, and both reverts taught me something I had wrong.

Nine mechanisms this week that looked like they worked and did not — and the last two are the ones I would least have predicted: the gates corrupting the files they were repairing, and a green build hiding a file the spec asked for and never got. Neither was a model limitation. Neither was visible until something counted. The suite stands at 281 tests, the model has not changed, and the bill is still $0.

All training, serving, benchmarking and Builder runs are local on an M1 Max with Apple MLX — total cloud spend: $0. The gates, the go/ast rewriters, the audit, the spec linter and the Builder loop: github.com/guildlm/builder.