A bar chart showing state fields growing from 2 in September 2025 to 10 by April 2026, then dropping to 1 in August 2026

AI Fixed Every Bug. I Ended Up With Ten State Flags.

Aug 13, 2026

On this page

Last week, while refactoring the Korean text input code in an editor I’ve been building, I found something odd: ten pieces of state spread across three files. Things like “are we composing right now?”, “is there a deferred key?”, and “is this scheduled save still valid?”

I went through the Git history and traced each one back to the change that introduced it.

There was no culprit.

Five bug fixes over eight months had added them, sometimes as many as four at once. Every fix made sense at the time. Most of them had been implemented by AI agents.

The problem wasn’t simply that there were too many flags. We had been careful to keep every bug fix small and contained. What nobody was watching was what those small fixes were doing to the shape of the code over time.

This is the story of why those ten pieces of state existed, how we got there without anyone making an obviously bad decision, and what I learned when I finally pulled them back together.

Don’t Touch the DOM Mid-Composition

Type a in English and the browser can insert a immediately. Korean input works differently. Typing , for example, goes through intermediate states: ㅎ → 하 → 한. Until the composition is committed, what you see on screen is still provisional.

Browsers expose that process through composition events.

compositionstart          "what follows is provisional"
  beforeinput  ㅎ
  beforeinput  하
  beforeinput  한
compositionend            "commit 한"

If the editor mutates the DOM at the wrong point in that window, composition can break. Characters disappear, consonants and vowels get duplicated, or the caret jumps somewhere unexpected.

But the rest of the editor doesn’t stop while composition is in progress. The user can press Enter. They can hit Cmd+Z. An autosave can fire.

Handle those actions immediately and you risk breaking composition. Drop them and you lose the user’s intent.

So the workable option is to defer them until composition ends.

And once you start deferring work, you need state to remember what you deferred.

At first, two fields were enough.

How Two Flags Became Ten

September 2025.

Pressing Cmd+Z during a Korean composition could break the composition. The fix was small: add a flag for “currently composing,” guard the undo entry point, and remember what should run after composition finishes.

let composing = false
let deferred: 'undo' | 'redo' | null = null

function onCompositionStart() {
  composing = true
}

function undo() {
  if (composing) {
    deferred = 'undo'
    return
  }
  runUndo()
}

function onCompositionEnd() {
  composing = false
  if (deferred === 'undo') runUndo()
  if (deferred === 'redo') runRedo()
  deferred = null
}

Two fields and a guard at the entry point. Small change, clear reason.

December 2025.

Then Enter caused trouble.

Unlike undo, Enter had to be replayed after composition ended so the line break could finish normally. But replaying a key introduced two new failure modes. The replayed Enter could overlap with another Enter from the browser and split the line twice. Or it could hit the same “defer during composition” guard again and loop.

Three more pieces of state appeared:

  • the deferred event
  • the next key to ignore after replay
  • whether a replay was in progress

March 2026.

We discovered that a composition could start and end without inserting any text. So we added another flag: “did this composition actually receive input?”

April 2026.

On Chromium on macOS, the final text may not be reflected in the DOM immediately after compositionend. Saving right away could capture stale content, so we had to wait one animation frame.

The moment we introduced “do this a little later,” we had scheduled work. And once work is scheduled, you need a way to tell whether it is still current.

That added a composition sequence number, the active composition ID, the pending deferred action, and an identity token to distinguish one scheduled action from another.

May 2026.

Then the scheduling logic itself produced a bug.

If the user typed Korean quickly enough, an old scheduled save could run after newer input and overwrite it with stale content. Characters disappeared.

This fix didn’t increase the top-level field count. We added a cancellation marker inside an existing scheduled action.

The field count stayed the same. The number of conditions the code had to reason about did not.

Eight months in, the code was carrying rules like these:

Rules the editor had learned about Korean input

  • Defer undo during composition and keep only the latest intent.
  • Replay Enter after composition ends.
  • Ignore one duplicate key immediately after a replay.
  • Don’t apply the “defer during composition” rule while replaying.
  • Don’t transform or save a composition that received no input.
  • Wait one frame after composition ends before saving.
  • Distinguish one composition from another.
  • Distinguish one scheduled action from another.
  • Starting a new composition invalidates an old scheduled action only for the same line.

Worse, those pieces of state didn’t even live in one file.

Input handling was split across three components: a keyboard handler for keydown events, an input handler for text and composition events, and the editor itself, which owned the save and undo entry points.

Each bug fix had put its new state wherever it was easiest to add at the time. Composition IDs and deferred saves lived in the editor. Deferred keys and replay state lived in the keyboard handler. “Did this composition receive input?” lived in the input handler.

Eventually, those components started reading each other’s state.

To answer “what state is input handling in right now?”, I had to read three files.

None of the Fixes Was Wrong

Look at the five fixes individually and it’s hard to point to one and say, “that was the mistake.”

A bug appeared. We added the state needed to describe it, guarded an entry point, and stopped there. We didn’t restructure the subsystem.

There were good reasons not to.

First, these bugs were expensive to reproduce. IME problems depend on event ordering and timing produced by the operating system and browser. Reproducing one often meant going through the real input path.

Second, restructuring poorly tested state is risky. If you redesign it in the middle of a bug fix and accidentally change behavior, you may not even know what you broke. A flag and a guard are small enough to reason about locally.

Third, every bug asked a slightly different question. We started with “are we composing?” Then came “did this composition receive input?”, “is this scheduled action still current?”, and “does it belong to the same line?”

When the existing state couldn’t answer the next question, another piece of state appeared. At each point, that was the smallest reasonable change.

And most of those changes were made by AI agents.

An agent usually works within the scope of the task in front of it. It doesn’t naturally stop and think, “we’ve added three similar flags to this subsystem over the last six months.” Tell it to fix this bug with minimal changes, and that is exactly what it will optimize for.

The locally correct fix was another flag.

We even had an explicit rule for our agents:

Don’t make changes outside the requested scope. If a broader change seems necessary, explain it first and get approval.

The rule existed for a good reason. We didn’t want an agent quietly redesigning a subsystem while fixing an unrelated bug.

And the rule worked. Every fix stayed small. There were no surprise refactors. The flags accumulated quietly instead.

The guardrail reduced the blast radius of each change. Watching the long-term buildup simply wasn’t its job.

The problem wasn’t minimal change.

We had nothing watching what all those small changes were turning into.

Adding one flag might cost only a line or two in the commit that introduces it. But other code starts reading it, combining it with existing state, and making decisions around it.

Each commit still looks small. The shape of the subsystem can change dramatically without any single change looking dramatic.

Turning Ten Pieces of State Into One

In August 2026, I finally refactored the state.

I did that with AI too. The tool hadn’t changed. The question had. Instead of “fix this bug,” the task was:

“Give this state one owner.”

I first added tests that captured the existing behavior. Then I collected the state scattered across three files into one value and made every transition go through a single function.

function next(state, event) {
  return {
    state: nextState,
    effects: [
      { do: 'replay-key', key: 'Enter' }
    ]
  }
}

The function doesn’t save anything or replay a real key. It takes the current state and an event, then returns the next state plus a description of what the outside world should do.

Now there is one place that can answer, “what state is input handling in right now?”

But the more interesting change was what happened to time.

Time Became Data

Many of the original bugs sounded like this:

  • What if the user types again very quickly?
  • What if a new composition starts before the scheduled save runs?
  • What if that new composition starts on another line?

These were all scenarios that used to require precise timing to reproduce.

Once state and events became values, I no longer had to reproduce the timing itself. I could just describe the sequence.

const state = replay([
  { type: 'composition-start', line: 'A' },
  { type: 'composition-end',   line: 'A' },
  { type: 'composition-start', line: 'B' }
])

expect(state.pendingSave).toEqual({
  line: 'A',
  id: 1,
  cancelled: false
})

This represents a specific timing-sensitive case: composition ends on line A and schedules a save. Before that save runs, a new composition starts on line B.

No setTimeout. No real keyboard input. No browser.

Before, a test might tell me that “a character disappeared.” Now it can tell me that “a composition on line B incorrectly invalidated a pending save for line A.”

There had been no unit tests for this state. After the refactor, there were 56. All of them run in 153 milliseconds.

The refactor actually increased the total amount of code. What decreased was how much of it I had to understand at once.

The numbers changed like this:

BeforeAfter
State10 fields across 3 filesOne state value
Direct mutation sites351, inside next()

This Still Doesn’t Prove Everything

A unit test for next() proves that if events arrive in a particular order, the transition logic makes the expected decision. It doesn’t prove that the browser actually produces that sequence.

I still need browser-level tests that type Korean through the real input path.

Their job is narrower now. Browser tests verify the boundary between the OS, the browser, and the editor. Fast unit tests verify the state-transition rules inside that boundary.

The boundary isn’t perfectly sealed either. Some real-world state still lives outside the pure transition function, and the state snapshot isn’t deeply immutable.

More importantly, the behavior itself did not change.

The rules discovered through eight months of bug fixes weren’t garbage to be deleted. They were knowledge we had learned from real failures. The problem was that this knowledge had been encoded as scattered flags and guards.

The refactor didn’t throw that knowledge away. It gave it one owner, put it in one place, and pinned it down with tests.

I’m Not Arguing Against Minimal Changes

Looking back, I still don’t think we should have redesigned the subsystem every time one of those bugs appeared. Redesigning poorly tested state in the middle of a bug fix could easily have been the riskier choice.

Minimal changes are still useful. What needs to change is how we watch what they add up to.

Two numbers stood out in this case:

  • How many pieces of state belong to this concern?
  • How many places read or mutate that state directly?

By the time I started the refactor, the answers were ten fields and 35 direct mutation sites.

But even the first number isn’t enough. The May 2026 fix didn’t add another top-level field. It added a cancellation marker to an existing scheduled action. The field count didn’t change, but the system gained another condition that other code needed to understand.

Some warning signs show up before the numbers get interesting. When state has more than one owner. When components start reading one another’s internal state. When answering “what state are we in?” requires opening more than one file.

In our case, those signs were already there in April. The actual refactor happened in August.

We were four months late.

The Better AI Gets at Small Fixes, the More This Matters

This kind of buildup matters even more when you work with AI agents.

An agent fixing a bug is focused on the problem in front of it and the tests it needs to pass. Within that scope, a minimal change is often exactly what you want.

But questions like these operate at a different level:

How many times have we added similar state to this part of the system over the last few months?
When did these components start reaching into each other’s state?

You don’t see those patterns by looking at one bug fix at a time.

After this refactor, I realized there was another rule I could add to the instructions I give my agents.

We already had this one:

Don’t make changes outside the requested scope. If a broader change seems necessary, explain it first and get approval.

I would now add something like:

Before adding a new state field, look for existing state that represents the same concern or shares the same lifecycle.
If the new behavior can be expressed through that existing state, don’t add another flag.
If understanding the current state requires combining several fields, or ownership of that state is spreading across components, consider giving it a single owner instead.

Moving state and events into a single transition model, as I eventually did here, is one possible answer. It isn’t the only one.

The point isn’t to ban flags.

It’s to make the agent ask one extra question before adding one:

Is there already state nearby that is trying to describe the same thing?

That doesn’t replace the minimal-change rule. It changes what happens before deciding what the smallest reasonable change actually is.

Birgitta Böckeler uses a useful term for this in her writing on harness engineering: a guide. A guide gives the agent direction before it makes the change.[1]

But a guide alone wouldn’t have caught what happened here.

The problem didn’t come from one bad change. It emerged gradually, across months of individually reasonable changes.

That calls for a sensor: something that looks at what the code has become, not just at how the next change should be made.

The hard part is that this problem isn’t completely deterministic.

Some checks are easy enough to automate:

  • Warn when a class or module accumulates more than a certain number of state fields.

  • Warn when the number of direct mutation sites for a piece of state crosses a threshold.

  • Fail when code reaches across a boundary to read or mutate state it shouldn’t own.

These checks are cheap, repeatable, and deterministic. In Böckeler’s terminology, they are computational sensors.[1]

But a threshold can only tell you so much.

Five state fields in one class aren’t automatically a design problem. Two fields can be enough to create a much worse one if their meaning depends on state hidden in three other files.

That was the real problem in my editor:

  • state for the same concern kept growing,

  • ownership spread across three files,

  • components started reading each other’s state,

  • and direct mutation had expanded to 35 sites.

Some of that is easy to count.

Field counts, mutation sites, dependency violations, and boundary crossings are all things a machine can detect reliably.

But this question is different:

Are these flags really fragments of one state machine?

A counter can’t answer that.

You have to understand what the fields mean, which lifecycle they belong to, and how they change together.

That’s where an LLM can be useful as a sensor.

For example, after enough changes have landed in the same area—or after a computational sensor raises a warning—I could ask an agent to review the subsystem with questions like:

Have recent changes repeatedly introduced state for the same concern?
Are several flags actually describing one lifecycle or state machine?
Is state ownership spreading across files or components?
Could any of the new flags be folded into an existing state model instead?

The trigger can still be deterministic.

Run the review after a certain amount of churn in one area. Run it when state or mutation counts cross a threshold. Run it periodically against parts of the codebase that change often.

The review itself doesn’t have to be deterministic.

It can be an inferential sensor: an LLM reading the code and making a semantic judgment that static analysis would struggle to make.[1]

I don’t think the choice is computational sensors or inferential sensors. They solve different parts of the problem.

Use deterministic checks for things that are cheap and unambiguous.

Use an LLM for the structural questions that require understanding what the code is trying to represent.

Böckeler explores this further in a follow-up on maintainability sensors, including static analysis, dependency rules, coupling data, and AI-based reviews of modularity. One point is especially relevant here: some sensors are more useful when they run repeatedly over time, because the thing you’re looking for is drift, not a single bad commit.[2]

If I were building the harness around this code today, I would add three things.

First, keep the existing guide:

Don’t make changes outside the requested scope.

Second, add a state-specific guide:

Before adding new state, inspect the existing state for the same concern and lifecycle, and see whether the new behavior belongs there.

Third, add a sensor outside the lifecycle of any single bug fix:

Track simple signals such as state count and mutation sites, and periodically have an LLM review ownership and the overall state model.

Looking back, what we were missing wasn’t another rule for making AI better at fixing bugs.

The AI was already fixing the bugs.

It was already following the minimal-change guide.

What we didn’t have was a loop that came back later and asked:

What did all those good, small fixes turn into?

Minimal changes make individual changes safer.

They don’t guarantee that a system stays simple after hundreds of them.

Teaching an agent how to make the next change is only half of the job. Something also has to look at what all those changes are becoming.


References

[1] Birgitta Böckeler, Harness engineering for coding agent users, MartinFowler.com, 2026.
https://martinfowler.com/articles/harness-engineering.html

[2] Birgitta Böckeler, Maintainability sensors for coding agents, MartinFowler.com, 2026.
https://martinfowler.com/articles/sensors-for-coding-agents.html