05

Chapter five · Days 28–38 · interleaved with the build

Debugging War Stories

Building with AI is fast. Debugging with AI is where the craft actually lives - because the model can’t see your screen, can’t run your app, and will confidently invent causes. Your job is to be its eyes, and to know when it’s guessing.

Debugging prompts: 38 War stories: 3 Longest single bug: 4 evenings Times the AI guessed wrong first: 3 of 3

The physics of AI-assisted debugging

Three truths before the stories. First: the model cannot observe. It only knows what you paste - so the quality of a debugging session is exactly the quality of your evidence gathering. Full crash logs, reproduction steps, and the actual file beat “it crashes sometimes” by an order of magnitude. Second: its first hypothesis is a guess wearing confidence. Treat first answers as leads, not verdicts. Third: rubber-ducking is real. Two of these three bugs were solved by the act of writing the reproduction steps carefully - before the AI even answered.

Severity · data loss

War story 1 - the predicate that hated empty shelves

Symptom. The firing list crashed on launch - but only on my partner’s phone, never in the simulator, never on my device. Fatal error: unexpectedly found nil deep in a SwiftData fetch. Four evenings, the longest bug of the project.

Wrong turn 1

The AI’s first theory: a migration issue. Two evenings of versioning ceremony that fixed nothing. Its confidence never wavered; its correctness did.

The tell

I finally compared devices properly: her library had a firing with zero entries - a kiln opened in disappointment and logged empty. My test data never did that.

The cause

A predicate chained force-unwraps through entries.first!. Empty array → nil → boom. Real users do things your fixtures don’t.

The fix

One prompt with the full crash log + the reproduction (“firing with no entries”): replaced the chain with compactMap and added an empty-state view. 20 minutes.

Lesson: the bug wasn’t in the code, it was in my test data’s imagination. Now every data-shaped feature ships with a “hostile fixture” - empty, enormous, emoji-named, and mid-sync states - generated by the AI, of course.

Severity · silent corruption

War story 2 - CloudKit’s ghost duplicates

Symptom. After enabling sync, some glazes appeared twice on iPad. Then three times. Deleting one deleted them all - then they came back. No crash, no error, just quietly multiplying pottery. The scariest class of bug: silent, intermittent, and touching user data.

Wrong turn 1

“Dedupe on appearance,” suggested the AI, and I nearly shipped it - a patch that would have hidden the corruption while it grew. The 40-minute rule fired: stop, ask what assumption is wrong.

The tell

Assumption found: SwiftData + CloudKit does not enforce uniqueness. Two devices creating “Shino #4” offline, then syncing, create two records. Correctly, by design.

The cause

I’d treated CloudKit like a server with constraints. It’s a merge log. My mental model was the bug - the code was doing exactly what the framework promises.

The fix

Stable UUIDs assigned at creation (not derived from content), plus a merge-on-import rule. The AI wrote it well - once the prompt contained the sentence “CloudKit is a merge log, not a database.”

Lesson: the most valuable sentence in a debugging prompt is often your corrected mental model. When the framework “misbehaves,” paste the framework’s actual contract into the chat and ask the AI to argue against your assumption. It will, gladly.

Severity · maddening

War story 3 - the iPad keyboard with opinions

Symptom. On iPad, in Stage Manager, entering a glaze name and tapping “save” would… dismiss the keyboard and eat the tap. Second tap worked. Only in Stage Manager. Only on the 11-inch. Only sometimes. The kind of bug that makes you gaslight yourself.

Wrong turn 1

Focus-state juggling - @FocusState gymnastics suggested across three prompts. Made it worse: now the keyboard flickered.

The tell

Screen-recording in slow motion showed the truth: the first tap was consumed by keyboard dismissal - the save button never received it. Not a focus bug. An event-routing fact of iPadOS.

The fix

One sentence prompt (“iPadOS eats the tap that dismisses the keyboard; make the toolbar save button use .scrollDismissesKeyboard(.never) semantics and resign first responder explicitly”) → fixed in one diff.

The meta-fix

Added to the ledger template: “record a slow-mo video before prompting.” Seeing beats describing, for you and for the model you’re briefing.

Lesson: for UI heisenbugs, your best debugging tool is the screen recorder, and your best prompt is a precise description of a single frame. “At 00:04 the tap lands while the keyboard is animating” is worth a thousand “it feels laggy.”

The debugging prompt template that emerged

By the end of the project, every debugging prompt followed the same skeleton. It’s in the Playbook as a copy-paste block, but here it is in situ:

the evidence-first debugging prompt
 Bug report. Read everything before proposing anything.

  EXPECTED: [one sentence]
  ACTUAL:   [one sentence, no adjectives]
  REPRO:    [numbered steps, the smallest case that fails]
  EVIDENCE: [full crash log / slow-mo observation / exact file]
  CONTEXT:  [device, OS, what changed in the last 3 commits]

  Rules: list your top 3 hypotheses ranked by likelihood,
  state what evidence would distinguish them, and do not
  write a fix until I confirm which hypothesis we're testing.
The AI is a brilliant mechanic on the phone. It cannot hear the engine. You are the noise it hears.
Ledger note · 2 Apr 2026
Practice · steal this

The debugging triad: (1) Evidence before hypotheses - full logs, minimal repro, slow-mo video. (2) Force the AI to rank three hypotheses and identify distinguishing evidence before any fix. (3) When stuck 40 minutes, stop patching and interrogate the architecture - and when a framework seems broken, paste its contract into the chat and ask to be proven wrong.