Days 14–17 — 4 Times My AI Said "Fixed" and It Wasn't: How Verifying Its Work Caught Every One

Days 14–17 — 4 Times My AI Said "Fixed" and It Wasn't: How Verifying Its Work Caught Every One

Day 13 ended with my friend's food blog — the WordPress-to-Next.js rebuild I'm doing with Claude Code, an AI coding agent in my terminal — finally live at its production URL. That turned out to be four days too early to celebrate. Over the next four sessions, one thing after another that looked done in production turned out not to be: every image broke with a silent 400, the admin panel rendered a totally blank page with no error anywhere, half the recipe bodies had been quietly truncated below the recipe card, and a routine deploy got wedged behind a login that said "succeeded" while still returning "denied." None of the four bugs crashed loudly. Each one meant tracing a real system — Next.js's config loader, a React flight payload, a WordPress import script, a Docker credential file — down to one exact line, while the model's confident first (and second, and third) theory kept getting overruled by evidence: a database dump, a byte-for-byte diff, a stray question about HTTPS. This is the four-day arc of turning "live" into "actually working," told as one story instead of four.


Models & effort

Who did what across these four sessions, and at what reasoning-effort setting — the receipts behind the workflow.

Task Model Effort
Day 14 — image-400 investigation (six rounds: pull-and-restart, curl checks, Node-level pattern matching, SSRF check, a manifest dead end, tracing loadConfig to its fallback) Sonnet 4.6 xhigh
Day 14 — Dockerfile COPY fix + redeploy Sonnet 4.6 high
Day 15 — blank-admin investigation (four theories: schema drift, stale image, HTTPS, architecture; RSC flight-payload diff; one-variable repro) Opus 4.8 xhigh
Day 15 — fix: unconditional s3Storage registration + importMap regen, verified both directions (707ee44) Opus 4.8 high
Day 15 — production evidence supplied by hand (DB dump, image-ID comparison, the HTTPS question) me, no model
Day 16 — truncation investigation: tracing the slice, quantifying the 61-recipe blast radius, re-diagnosing two "unplaceable" video anchors Opus 4.8 xhigh
Day 16 — stripRecipeCard() helper (TDD) + import-script swap Opus 4.8 subagents, reviewed high
Day 16 — re-anchoring the last 4 video clips into backfill CSVs; production rollout over SSH tunnel with read-only pre/post checks Opus 4.8 high
Day 17 — GHCR deploy-auth debugging (expired PAT, sudo credential-file mismatch) Opus 4.8 high
Day 17 — recipe page cleanups (hero below title, category pill removed, backed by a SQL check) Opus 4.8 high
Day 17 — comment "Author" badge default + public-route guard + migration Opus 4.8 high

TLDR

  • Day 13 ended with the site "live." Four sessions later, four separate things about production turned out not to actually work.
  • Day 14 — images: every optimized image 400'd in production. Six rounds of elimination (stale image? pattern match? SSRF? manifest?) traced to one missing COPY next.config.ts in the Dockerfile runner stage — without it, next start silently fell back to remotePatterns: [] and rejected every remote image.
  • Day 15 — admin: /admin rendered a blank page in production and, it turned out, had never worked there. Three of the model's four theories (schema drift, stale image, arch mismatch) were wrong and were each killed by evidence I supplied — a DB dump, matching image-ID digests, a byte-diff of JS chunks — before a flight-payload diff and a one-variable repro found the real cause: the R2 storage plugin was only registered in Payload's config when R2 env vars existed, so its client component never made it into the importMap generated during the (var-less) Docker build.
  • Day 16 — content: a side-by-side comparison against the old WordPress site caught a recipe missing everything after its recipe card — a whole bonus section, gone. Root cause: an import script from an earlier session sliced every recipe body at wp:zip-recipes and discarded the rest, silently truncating 61 of 123 recipes. Bonus finding: the same bug had already been mis-diagnosed, in my own notes, as the cause of 2 of 6 "unplaceable" video anchors.
  • Day 17 — deploy: a routine push got stuck behind denied: denied even after docker login reported "Login Succeeded." The server's GHCR token had expired, and a fresh login still failed because sudo docker pull reads a different credential file than the user-level docker login had just written to.
  • Recurring shape across all four: nothing crashed loudly, each bug was a plausible-looking thing quietly not working, and the fix came from checking real evidence — database, logs, diffs, source code — rather than the model's first confident theory.
  • End state: images load, admin loads, all 130 recipe bodies plus 51 in-body videos and the Instagram/YouTube sidebar are live in production, and deploys are unblocked.

Day 14 — The image bug that survived every fix

Day 13's fix list said "waiting on the server to pull and confirm images load." It didn't confirm. Every optimized image threw the same "url" parameter is not allowed 400, in the browser and from a raw curl.

Six rounds, each one ruling something out before moving on:

  1. Pull the new image and restart. Still 400 — rules out "stale Docker image."
  2. Check the obvious things independently. R2 files return 200 directly. The URL in the rendered HTML matches remotePatterns exactly.
  3. Test the pattern-matching code itself, pulled straight from node_modules and run in Node with the real config: hasRemoteMatch(...)true. Not a pattern problem.
  4. Check the SSRF private-IP guard in image-optimizer.js. R2's IPs resolve as unicast, not private. Not that either.
  5. Read required-server-files.json — missing pathname, looked suspicious, turned out to be a dead end: that manifest only matters for output: 'standalone' builds, which this isn't.
  6. Trace loadConfig() in config.js all the way to its else branch: when next.config.ts isn't found, Next.js silently uses defaultConfig, and defaultConfig.images.remotePatterns is [].

The Dockerfile runner stage copied public/, .next/, node_modules/, package.json, and Payload's config — but never next.config.ts. The builder stage has it and bakes remotePatterns correctly into the build. The runner stage never gets the file next start needs to re-read it at boot. One line fixed it:

COPY --from=builder --chown=nextjs:nodejs /app/next.config.ts ./next.config.ts

Flop, twice over: the day-13 fix (pathname: '/**' added to remotePatterns) was believed to be the cause. It compiled, passed tests, and did nothing — matchRemotePattern already defaults pathname to '**', so both forms matched identically. It was never the bug. Win: at every step, the actual matching code and actual config ran in Node before a new theory was proposed — no wasted deploy cycles chasing a config tweak that wouldn't have helped.


Day 15 — The blank admin page and four wrong theories

My friend went to log into /admin and got a blank white page — no crash, no error, nothing. It turned out the admin had never worked in production since launch.

Four theories came up, three of them the model's, delivered with confidence each time — and each one died to a piece of evidence, not to reasoning:

  • "Schema drift" — the repo has a whole documented runbook for exactly this symptom. Killed in five minutes: the React Server Components stream showed a clean NEXT_REDIRECT to create-first-user, which only happens if the users query succeeds and returns zero. The schema was fine.
  • "Stale Docker image" — proved wrong three ways (local build worked, the actual Docker image worked, CI had built the current commit) and confidently recommended a redeploy. I redeployed. Still blank. Then I pulled the running container's image digest and the freshly-pulled one and put them side by side: identical. Not stale.
  • A stray question, not a theory — I asked whether HTTPS itself could be the cause. It wasn't (no CSP, no mixed content, correct MIME types) — but the question forced the right framing: code, image, and DB were all identical to a working local copy, so the difference had to be the runtime environment.
  • "amd64 vs arm64 Turbopack difference" — a whole emulated amd64 build got kicked off to test this. While it ground away, an md5 comparison of the deployed JS chunks against local ones came back byte-for-byte identical. The architecture theory was dead before the build even finished; the slow build was pure waste.

The actual answer came from diffing a React flight payload from production against a working local instance: one node rendered null in prod where it should have held the form. Reproducing it locally took flipping exactly one variable — enabling the R2 vars that gate the storage plugin — and the blank reappeared, with the server log finally saying it outright:

getFromImportMap: PayloadComponent not found in importMap { key: '@payloadcms/storage-s3/client#S3ClientUploadHandler', … }

The R2 s3Storage plugin was only added to the Payload config when R2 env vars existed. The importMap — which tells the admin which components to load — is generated inside the Docker build, where those vars don't exist. So the plugin's client component never made it into the map. Production does have the vars, the plugin activates, tries to render a component the map doesn't know about, and the whole layout collapses to null with no error anywhere. The fix: register the plugin unconditionally and gate its behavior, not its presence, with enabled: Boolean(...).

The pattern worth naming: three times the model reached for an explanation about the artifact (the schema, the image, the architecture) when the bug was in how the configuration interacted with the environment. Every time, what redirected it was something I handed over — a database dump, matching digests, a question about the runtime — not a sharper prompt.


Day 16 — The content that vanished below the recipe card

My friend, comparing the new site against the old one recipe by recipe, found one that just stopped: a "family size" variation, photos, a closing video — all present on WordPress, all gone on the rebuild. Her instruction was the right one: understand it before fixing it.

The culprit was three lines in patch-body-images.ts, an import script from an earlier session:

const recipeCardIdx = content.indexOf('<!-- wp:zip-recipes')
const body = recipeCardIdx > 0 ? content.slice(0, recipeCardIdx) : content

slice(0, recipeCardIdx) kept everything before the recipe card and threw away everything after it — reasonable if the card always sat at the bottom of the post. It doesn't. My friend routinely writes variations, tips, and closing photos below it.

Before proposing any fix, the actual blast radius got quantified: a first pass claimed zero affected recipes, which was obviously wrong and got thrown out rather than trusted — the bug was in the scan itself (it assumed a closing <!-- /wp:zip-recipes --> comment that doesn't exist; the block is self-closing). Fixed the scan: 61 of 123 recipes had real content after the card, about 14 with substantial prose and 47 with at least a trailing photo. Three posts had more than one card, so the original slice had also been eating content between cards.

The sharper flop was underneath the obvious one. A previous session's video-backfill had left 6 recipes on a "manual placement" list, with notes confidently attributing it to WordPress headings the migration had dropped. Two of those six turned out to be wrong diagnoses — the anchor text hadn't been dropped, it had been truncated, because it lived below the recipe card. My own documentation had recorded the wrong cause, and it only surfaced because my friend asked a follow-up question about the video anchors instead of accepting the fix as complete.

The actual fix was two lines — a pure stripRecipeCard() helper, TDD'd against three cases, swapped in for the slice. The harder discipline was what came after: the plan's remaining "manual placement" list (now down to four) assumed those four videos would get hand-placed in the admin. That assumption was actively dangerous — the body-rebuild step reconstructs each recipe's intro from WordPress source every time it runs, which means anything placed by hand in the admin gets silently wiped on the next content-pipeline run. So instead of accepting "a bit of manual work," the last four clips got re-anchored to verified-live paragraphs and wired into the backfill CSVs. The manual list went to zero — not by skipping work, but by refusing a fix that would have quietly undone itself.

Shipping to production was deliberately unglamorous: a read-only SELECT-only check first to prove the tunnel was pointed at prod in its known pre-fix state (130 recipes, family-size content still missing, zero video blocks), then the real pipeline run, then the same read-only check again to confirm the fix landed (family-size content present, 51 video blocks). A full dry-run of the whole pipeline against prod was explicitly skipped — not from bravado, but because the rebuild step doesn't write anything in dry-run mode, so the video-backfill step downstream would see still-truncated bodies and report a pile of false "missing anchor" failures. Understanding the data flow well enough to know a dry-run would lie is its own kind of check.


Day 17 — Login succeeded, access denied

A routine deploy of small changes stalled on docker compose pull:

Error response from daemon: Head "…/manifests/debug": denied: denied

The first instinct — "the GitHub login expired" — checked the wrong login. gh auth status on the laptop was fine; the actual credential was the server's separate GHCR personal access token, and it had expired. A fresh PAT, a docker login, "Login Succeeded" — and the very next pull was still denied.

The detail that broke it open: the pull was run with sudo. docker login (no sudo) had written the credential to the regular user's ~/.docker/config.json. sudo docker compose pull reads /root/.docker/config.json — a different file, with nothing in it — so root was pulling the private image anonymously and getting correctly denied. "Login Succeeded" was true; it just succeeded for the wrong user. Fix: sudo docker login, then the pull worked.

The same session carried two smaller, sharper moments. Asked to make the comment "Author" badge checkbox default to checked in the admin — a one-line defaultValue flip on paper — the model caught first that the same field is shared with public visitor comments, which never set it explicitly and rely on the default being false. Flipping the default naively would have badged every visitor comment as "Author." The real fix was two-part: default true in the collection (the admin convenience), and an explicit authorReply: false in the public API route as a permanent guard, plus a migration.

The mirror-image moment: asked how to run the day's migration against prod, the first answer was a careful manual two-step — deploy, then separately run migrate over the SSH tunnel. Wrong, and corrected only a message later after re-reading the project's own runbook: production auto-applies pending migrations on boot, so the deploy alone was sufficient. The right answer existed in a committed document the whole time; it should have been the first stop, not the second.

Also: the category pill duplicating the breadcrumb on every recipe page turned out not to be a guess — a SQL query confirmed all 130 recipes have exactly one category, so the pill (which showed all categories) was guaranteed identical to the breadcrumb on every single page. Removed with evidence, not a vibe.


The thread through all four days

Four different systems, four different failure modes, and the same shape underneath every one of them: nothing crashed in a way that pointed at its own cause. An image request returned a 400 that could mean two unrelated things. A page rendered blank with zero console errors. A recipe body looked complete because nothing about truncation throws. A login said "succeeded" while the very next command said "denied." Every one of these bugs looked finished, or at least legible, right up until someone checked the real thing underneath it.

And every real fix in these four days traced back to evidence overruling a plausible story: a database dump killing a schema theory that matched the symptom on paper, matching image digests killing a stale-deploy theory, a byte-diff killing an architecture theory, a flight-payload diff finding the actual null node, a direct SQL count confirming a UI element was pure duplication, a read-only pre/post check proving a production rollout actually did what it claimed. None of those checks are exotic. They're just the discipline of trusting the log, the database, and the diff over confident-sounding output — the same discipline day16 named directly as "vibe engineering, not vibe coding."


End of these four days

Done:

  • Production images loading (Dockerfile now copies next.config.ts into the runner stage)
  • Production /admin loading (R2 storage plugin registered unconditionally, gated by enabled, importMap regenerated)
  • All 130 recipe bodies restored past the recipe-card truncation; 61 recipes' worth of recovered content shipped to prod
  • All 51 in-body recipe videos auto-placing via backfill CSVs — the "manual placement" list went from 6 to 0
  • Instagram/YouTube sidebar configured and live in production
  • Server's GHCR deploy credential refreshed and the sudo/user credential mismatch resolved
  • Comment "Author" badge defaults on in admin with a hard-guarded public route; recipe hero image and duplicate category pill cleaned up
  • Full test suite green throughout (104 → 218 tests across the four sessions as features were added)

Not done / follow-up:

  • Nothing outstanding from these four days specifically — each session closed with its own fix verified in production before moving to the next

Honest take on these four days

I keep coming back to the same observation across all four sessions: the model is an excellent systematic investigator once it's looking at the right evidence, and a confident, occasionally wrong storyteller when it isn't. Day 14's six rounds worked because each hypothesis got tested against real code before the next one was proposed. Day 15's three wrong theories all had a superficially reasonable shape — a documented runbook, a plausible stale-deploy story, a real architecture difference between build machines — and every one of them fell to something concrete I put in front of it, not to better reasoning on its own. Day 16 is the one that unsettles me most in retrospect: my own notes, written by the same collaboration, had confidently recorded the wrong root cause for two bugs, and it took a follow-up question to notice.

The thing I'd want a reader to take from four days like this isn't "the AI got it wrong a lot," though it did. It's that the failures were never random — they were all the same recognizable shape, a plausible-looking artifact quietly missing one piece, and once you know to expect that shape, the response is always the same: read the actual source, query the actual database, diff the actual output, and don't ship a fix — or a redeploy, or a "manual" checklist — until the evidence, not the story, says it's right.


How to verify AI work: the playbook these four days taught

None of the checks below are exotic. That's the point — they're cheap, they're specific, and every one of them is the actual move that overturned a wrong theory somewhere in this post.

  1. Run the real logic, not a description of it. Day 14's pattern-matching theory got tested by pulling the actual hasRemoteMatch function out of node_modules and running it in Node with the real config — not by reasoning about what it probably did. If you can execute the exact code path in question, do that instead of arguing about it.
  2. Diff artifacts, don't compare descriptions of them. "Same image" was proven with matching SHA256 digests, not a timestamp. "Same JS bundle" was proven with md5s of the actual deployed chunks, not an assumption about the build pipeline. A byte-diff ends an argument that reasoning can't.
  3. Query the database directly. Day 15's schema-drift theory and day17's "is this pill always redundant" both got settled by an actual SQL query against production or the real schema — not by trusting a runbook or an assumption about the data.
  4. Read the source of the tool, not just its docs. The Next.js image-config bug was only found by reading loadConfig()'s fallback branch in the framework's own source. Docs describe the common path; they rarely narrate the silent fallback.
  5. Prove your starting state before you mutate anything. Day 16's production rollout ran a read-only, SELECT-only check first — confirming the tunnel was actually pointed at prod, in its known broken state — before running anything that wrote. Then the same check again after, to confirm the fix landed, not assumed.
  6. Distrust a check if you understand why it would lie. A full dry-run of the day-16 pipeline was skipped on purpose: the rebuild step doesn't write in dry-run mode, so the step after it would see stale data and report false failures. A "safer-looking" verification step isn't safe if you know its output would be misleading.
  7. When something's flagged "needs manual work," ask why — and what happens to it next. Day 16's four "manually placed" videos were actually one bad assumption away from being silently wiped by the next automated run. The question that caught it wasn't a code review, it was "why is this manual, and does anything downstream undo it?"
  8. A confident write-up is not evidence. My own notes from an earlier session had recorded the wrong root cause for two bugs, calmly and specifically. Notes, runbooks, and prior conclusions are hypotheses with good production values — they still need to be checked against the thing they describe.

This is part of an ongoing series documenting the rebuild of a friend's food blog from WordPress to a custom Next.js stack, built with AI assistance.

Kumudu
Written by

Kumudu

Distributed systems, infra, and small AI experiments on weekends. New posts roughly monthly, mostly long.

Comments

Adjacent reading

all essays →
press esc to close · enter to search