Writing code faster than you can review it

2026-04-12
aiarchitecture

I can't review code as fast as an AI can write it. Nobody can, because the gap is multiple orders of magnitude wide, and I think it gets harder to ignore the problem the longer you sit on it. I'll call it the generation-verification scaling problem in this post.

I'm not complaining about AI, nor even about the speed of AI development. We can still verify AI-generated code the old way, line by line, if we're willing to slow down. I don't want to give give up all the speedup to do that. I'd treat this as another optimization problem: bank the speedup by making the codebase and tooling mechanically prevent the failure modes we care about, so reviewers don't have to catch them by hand. Both speed and correctness go up.

The instinct is to treat the generation-verification scaling problem as a review problem, i.e. "just review more carefully bro", or review more often. But that's a losing position. The engineer's job now is to be the architect of verification systems: build a codebase where the structure itself rejects mistakes, so that precious human cognitive load is preserved for higher-value judgment calls.

The wrong answer

One tempting response to the generation-verification scaling problem is to lean on more instructions as a review layer: CLAUDE.md files, AGENTS.md, elaborate system prompts with coding standards and checklists. The idea is that the agent will follow them.

The problem with this is that the compliance here is non-deterministic. In transformer models, whether an instruction is followed or not completely depends on how attention scores weight those instruction tokens relative to everything else in the context window at generation time. It's not a rule that fires reliably. This is compounded in long contexts where tokens far from the current generation position receive systematically lower attention scores. The same instruction can be effectively ignored in one run and followed in the next.

There's no formal guarantee the model attended to all the rules in your CLAUDE.md, AGENTS.md, system prompts, skills, and whatever other brute context injection methods at all. Throwing more markdown files at the problem doesn't change the underlying mechanism. You're hoping (begging?) the model remembers your instructions.

We need deterministic guarantees.

Correctness by proxy

I can't read every line of agent-generated code, so I've moved the verification I used to do at review time into tools that run before I see the diff. Hooks, static analyzers, and linters used to enforce style and obvious bugs. Now they enforce the contracts the agent has to satisfy when writing the code.

Go's golang.org/x/tools/go/analysis package makes this concrete. You can write custom analyzers that plug directly into go vet or golangci-lint, with full access to the AST and type information, and emit diagnostics with suggested autofixes. That means you can enforce conventions specific to your codebase, i.e. the stuff that an out-of-the-box staticcheck will never know to look for.

For rewrites rather than diagnostics, Go has golang.org/x/tools/cmd/eg: an example-based refactoring tool that lets you define source-and-target transformation templates:

// template.go
import "yourpkg"

func before(addr string) *yourpkg.Client { return yourpkg.NewLegacyClient(addr) }
func after(addr string) *yourpkg.Client  { return yourpkg.NewClient(addr) }

Run eg with that template and it rewrites every matching call site across the codebase automatically. You're defining a deterministic migration rule that can be re-run whenever the agent reaches for a deprecated pattern. The thread across these tools is the same: errors surface early and automatically, so human attention isn't burned on things the toolchain can catch.

Push correctness left

The further right a bug is caught (in tests, in review, in staging, in production) the more expensive it is. That's not a new idea. What's new is that you can't lean on tests to close the gap anymore when agents are also writing the tests. Agent-generated tests have the same problem as agent-generated code i.e. they can be quietly wrong, and they skew towards the happy path. Treating them as your primary correctness signal is asking the agent to assign itself homework and grade it too.

I'd like to rely more on compile-time guarantees instead. Go's type system gives you concrete tools here (not unique to Go, I am just a Gopher).

Sealed interfaces are one. An interface with an unexported method can only be satisfied by types in the same package:

type ItemOperation interface{ itemOp() }

func (CreateItem) itemOp() {}
func (UpdateItem) itemOp() {}
func (DeleteItem) itemOp() {}

External code, including agent-generated code, cannot define a new type that satisfies ItemOperation. The unexported method is a compiler-enforced lock: you have to be inside the package to add a new valid operation. The set of valid operations is closed, and closing it is a deliberate design decision. The stdlib does this too: testing.TB has an unexported private() method for exactly this reason, i.e. to prevent external packages from implementing it.

Typed primitives are another. If UserID and PostID are both string, an agent can wire them interchangeably and the compiler won't notice. If they're distinct named types, the mix-up is a build error. A one-line change that eliminates an entire class of mistakes. The stdlib equivalent is time.Duration: a named type over int64, so passing a raw integer where a Duration is expected is a compile error, not a silent unit mismatch at runtime.

Encoding behavior in the value itself is another. Consider a PATCH endpoint where clients send a list of field names alongside their values, so the server maps each field name to a specific mutation on a query builder. The naive approach is a switch on the field name:

// Don't do this.
for _, field := range req.UpdateMask {
    switch field {
    case "name":
        builder.SetName(*req.Data.Name)
    case "email":
        builder.SetEmail(*req.Data.Email)
    // Agent adds a new case here but misses an identical switch elsewhere.
    // Compiles fine. Silent no-op at runtime.
    }
}

An agent adding a new field case has to find and update every parallel switch on field name across the codebase. If it misses one, it'll get a silent no-op because nothing errors, nothing panics, and the only way to surface it is a test. That puts you back in relying on tests, which we argued isn't reliable if the agents write the tests.

The fix is to pair each field name with the mutation that should run when that field is in the mask:

type FieldApplier struct {
    Field string
    Apply func(*QueryBuilder) *QueryBuilder
}

Adding a field is a new entry in one place. There's no switch to forget. The closure carries the behavior, and the compiler enforces that every applier targets *QueryBuilder. The silent-no-op category disappears.

Patterns that felt like overengineering two years ago are worth reconsidering here. The human-readability cost shrinks and the structural safety benefit remains.

Skewering concerns with generics

Generics give you compile-time safety across multiple types without falling back to any. One abstraction can hold multiple independently-varying axes. The standard library example is a container and its element type: []T, map[K]V. The applier from the previous section becomes another once you generalize it: a target type and the value being applied to it.

A concrete instance, drawn from a field-mask PATCH layer:

type FieldApplier[B any] struct {
    Key   MaskKey
    Apply func(B) B
}

func SetOnly[B any, V any](key MaskKey, val *V, set func(V) B) FieldApplier[B] {
    return FieldApplier[B]{
        Key: key,
        Apply: func(b B) B {
            if val != nil {
                return set(*val)
            }
            return b
        },
    }
}

func ApplyMask[B any](builder B, mask []string, appliers []FieldApplier[B]) B {
    set := NewMaskSet(mask)
    for _, f := range appliers {
        if set.Contains(f.Key) {
            builder = f.Apply(builder)
        }
    }
    return builder
}

Call sites read as declarative wiring, i.e. one line per field, target and value types inferred from the method value:

shared.ApplyMask(b, req.UpdateMask, []shared.FieldApplier[*ent.CarrierUpdateOne]{
    shared.SetOnly("name", data.Name, b.SetName),
    shared.SetOrClear("abbreviation", data.Abbreviation, b.SetAbbreviation, b.ClearAbbreviation),
    shared.SetTransform("total_weight", data.TotalWeight, decimal.NewFromString, b.SetTotalWeight),
})

[B any] covers the target, [V any] covers the value, [T any] (on SetTransform) covers the post-transform type. Three helpers cover every target × every value type they're used against. A working version of this lives in internal/shared/fieldmask.go.

The same shape applies anywhere a pattern recurs across two or more independent axes. One abstraction holds all of them; the alternative is N×M near-duplicate helpers, or one helper that takes any and surrenders the type checking the rest of this post is arguing for.

Fail fast at initialization

Not everything can be caught at compile time. For errors that are fundamentally about runtime configuration, bad env vars, missing dependencies, misconfigured wiring, the next best thing I've found is to fail at startup rather than per request.

The Go standard library has a clear pattern for this: Must* wrappers. regexp.MustCompile and template.Must panic on bad input during initialization rather than returning errors callers have to handle on every call. The tradeoff is intentional: if the configuration is wrong, crash immediately rather than silently degrading on live traffic.

The same principle applies at the application level. Config validation, dependency wiring, schema checks: these should all run at main() time:

func mustLoadConfig() Config {
    cfg, err := loadConfig()
    if err != nil {
        slog.Error("failed to load config", "err", err)
        os.Exit(1)
    }
    return cfg
}

The same pattern applies to any wiring derived from struct tags. Rather than scattering raw string literals across the codebase and hoping they stay in sync with the struct they reference, you can derive them once at package initialization time and panic immediately if the field doesn't exist or has no tag:

// Panics at startup if UpdateRequest has no field "Email", or if it has no `json` tag.
var FieldEmail = mustFieldKey(UpdateRequest{}, "Email")

func mustFieldKey(v any, fieldName string) string {
    t := reflect.TypeOf(v)
    f, ok := t.FieldByName(fieldName)
    if !ok {
        panic("no field " + fieldName + " on " + t.Name())
    }
    tag := f.Tag.Get("json")
    if tag == "" {
        panic("field " + t.Name() + "." + fieldName + " has no json tag")
    }
    return tag
}

The struct tag and the key variable are now the same source of truth. An agent that renames the field or removes the tag gets a panic at startup, not a silent mismatch three call frames deep.

A subtler version of the same pattern is the blank import. Some packages do their wiring via init(), registering validators, hooks, or interceptors, and are pulled in with a blank import:

_ "yourapp/internal/runtime" // registers entity validators and hooks via init()

The init() function is where the fail-fast checks live: if a validator fails to register or a required hook is missing, the process panics at startup rather than silently skipping validation on live traffic. The blank import is what ties the wiring in. As long as it's there, the initialization fires deterministically, before the process serves a single request. An agent generating a new service handler that relies on those validators gets the fail-fast behavior automatically. The per-handler surface area stays clean because the wiring is already done.

The new baseline

Part of the job now is making architecture decisions that agent-generated code can work with safely. The question I keep asking myself is whether the structure catches buggy code before it ships, without me having to catch it.

None of this is new. Static analysis, type constraints, fail-fast initialization, all these have been good ideas for years. They just matter much, much more now. I don't see the gap between how fast agents write code and how fast humans can review it closing, so the structure has to do more of the work.

I don't think it's a huge leap in logic to say that a maintainable codebase in 2026 means one that's maintainable by agents too.