AI Engineering Field Guide
Start here · 10 minutes

10 Minutes That Can Change How You Build AI Systems

These are not theoretical rules. They came from things that actually broke.

10 min core principles ↓ 30 min practical path ↓ deep dive full guide below

AutoAI started as “an AI application.” Wire up a model, generate some articles, add a chatbot. How hard could it be?

Then the seemingly simple features started opening trapdoors.

  • The LLM returned perfectly valid JSON — with the wrong shape inside.
  • Vectors existed in PostgreSQL, the embedding API returned 200 OK, and the chatbot still knew nothing.
  • Articles were published while their agent runs sat frozen in “waiting for human”.
  • Login worked — but only after refreshing the page.
  • Switching language flipped text direction without changing the actual language.
  • A “View Article” button appeared before any article content existed.
  • Voice settings rendered a full panel of options the backend never read.
  • Everything passed locally and broke in production anyway.
The difficult part of AI software is rarely “calling the model.” It is engineering everything around the model: configuration resolution, state machines, retrieval pipelines, grounding policy, render boundaries, data lifecycles — and knowing when to stop building.

What follows is the compressed version: twelve principles, each earned through a real failure. If one of them makes you think “wait — does MY system do that?”, follow its link into the deep dive.

§The 10-Minute Core

Twelve principles. Each one is short on purpose — each links to the full investigation.

JSON Is Not a Contract

An LLM can return perfectly valid JSON that is completely wrong for your application.

AutoAI’s model kept returning an object where the contract demanded a string inside outline[] — three retries, same wrong shape, because the nested schema never reached the model.

“If my model returns valid JSON, what exactly have I proved?”

Read the full case study →

A Vector Is Not RAG

Having vectors in PostgreSQL proves vectors exist. It does not prove retrieval works.

The production database had real 2048-d embeddings while the chatbot answered “No relevant knowledge found” to everything.

“Can I prove every stage between the user’s question and the retrieved chunk?”

Explore the RAG chapter →

IDs Are Not State

A post can have an ID and no usable content. A run can exist and be stale. A document can exist and be invisible.

“View Article” appeared during generation because a placeholder post was reserved up-front — with empty content.

“Which of my UI actions are gated by IDs instead of business state?”

Read more →

Configuration Is Architecture

Correct code can still behave incorrectly because configuration resolution is wrong.

All nine AI roles pointed at a demo mock in production because env fallbacks fired where variables were missing — while the real key sat unconnected in another settings surface.

“For every external dependency: what is my source → precedence → validation → fallback?”

Read more →

Models Have Capability Contracts

Never assume a model supports a feature — structured outputs, context length, pricing — verify against its live catalog.

Request tiers (json_schema → json_object → none) exist because capability flags differ per model and drift over time.

“If my provider changes this model tomorrow, what breaks silently?”

Read more →

Pipelines Are State Machines

An agent workflow is a persisted state machine with human gates — not a sequence of AI calls.

Published articles kept a run stuck in waiting_for_human: finalization updated one pointer while regeneration had created multiple runs per post.

“When this workflow terminates, which related executions must sweep to terminal state?”

Read more →

Grounding Must Be Deterministic

A knowledge-grounded assistant answers ONLY when retrieval proves relevance — otherwise it refuses without calling the model at all.

Asked about a president, AutoAI once answered from world knowledge next to a “no relevant knowledge found” banner. Both statements were true; zero grounding present.

“What stops my chatbot from being helpful outside its knowledge base?”

Read more →

Find the First Divergence

When output is wrong, decompose the path into measurable stages and find the earliest point where reality differs from expectation.

One diagnostic funnel turned three separate “RAG is broken” incidents into three precise root causes.

“Am I fixing the stage that lied — or the stage that looks suspicious?”

Read more →

Local ≠ Production

Local ≠ Vercel ≠ Neon ≠ OpenRouter. Each boundary adds failure modes your laptop never exercised.

Env-default fallbacks fire only where variables are missing — which happens first in production. File-based local databases are single-writer; smoke scripts see a different world than the app.

“Which of my assumptions survive the deploy boundary?”

Read more →

Production Proof Closes the Loop

A feature is finished when the actual deployed path passes — observed, not assumed.

Health endpoints, authenticated diagnostics, and network-level evidence turned “should work” into measured facts (12/12 calls to the right provider, exact similarities, zero non-200s).

“What would I need to SHOW someone that this works in production right now?”

Read more →

UI Mirrors Business State — or Lies

Every control must derive from meaningful backend state, never from identifier existence or timers.

A voice-settings panel offered provider/model fields the reply pipeline never read, while omitting what it did consume. Settings UIs must expose the intersection of changeable and consumed.

“Does every option in my UI actually do something?”

Read the case study →

V1 Needs a Stopping Rule

Done = acceptance criteria passing in production. Not out-of-ideas. Not tired.

AutoAI’s freeze memo listed forbidden files by name while the last two fixes shipped — that memo is why the release happened.

“Is this defect blocking the core workflow — or just bothering me?”

Read more →

§When Something Breaks, Don’t Guess.
Find the First Divergence.

This is the debugging method behind almost every fix in this guide — drawn here as the path every request takes through a RAG system:

EXPECTED behavior
↓ defined as acceptance criteria
INPUT — user question arrives
PROCESS — embed · filter · rank
DATABASE — candidates from pgvector
↓ identity + status + source-type filters
RETRIEVAL — top-K chunks above threshold
MODEL — answer generation
OUTPUT — grounded answer + sources
When the OUTPUT is wrong, the bug is often not at the output. In AutoAI the model was innocent every time — the divergence lived earlier: an inactive document, a null embedding identity, a language-coupled filter, a swallowed indexing error. Measure each stage, find the FIRST stage where reality differs from expectation, and fix exactly that. Then write the regression test so it can never lie again.

The same method found the login bug (auth ✓ → cookie ✓ → navigation ✗) and the stuck agent runs (publish ✓ → finalize ✗). It works because it converts “something is broken” into “this stage returns something different from that expectation.”

§Got 30 Minutes?

The practical path — seven chapters, roughly four minutes each, in the order that builds the mental model fastest.

1 · architecture

Understand the architecture

Sources of truth, failure boundaries, and why “where does truth live” decides everything downstream.

Architecture Before Implementation →
2 · ai contracts

Understand AI failure modes

Model capabilities, structured outputs, validation layers and fallback tiers.

AI Providers & Model Abstraction →
3 · rag

Understand RAG

The eleven-stage funnel, seven real ways it broke, and the checklist that finds the lying stage.

RAG: Never Trust the Surface →
4 · agents

Understand agent systems

Persisted state machines, human gates, multi-execution entities and terminal sweeps.

Agent Pipelines Are State Machines →
5 · production

Understand production

Every boundary between local and live — and the proof standard that closes them.

Local ≠ Production →
6 · debugging

Learn the debugging discipline

Expected vs observed, stage decomposition, first divergence, regression lock.

First-Divergence Debugging →
7 · evidence

See real failures

Seven production incidents with symptom, wrong assumption, root cause and transferable lesson.

Case Studies →

§Starting a New AI Project Tomorrow?

Answer these twelve questions BEFORE writing code. Your future self debugs faster when these have answers. Click items as you settle them — progress is saved in this browser.

  1. What does “done” mean — observably?
  2. What is explicitly V1?
  3. What is explicitly V2?
  4. What is the source of truth for each piece of state?
  5. Where is the state machine — including human gates?
  6. What happens when the AI returns malformed data?
  7. What happens when the provider is unavailable?
  8. How are embeddings identified (provider/model/dimensions)?
  9. How will I PROVE RAG works — stage by stage?
  10. How do I prove production works, not just local?
  11. What is my rollback strategy?
  12. What will make me stop working on this?
Deep dive

You now know the rules. Here’s where they came from.

None of the principles above were invented beforehand. They were extracted — sometimes painfully — from real failures, debugging sessions and production verification runs. Below sits the complete library: twenty chapters, the full checklist, and seven case studies with the actual investigations.

AI contract · intermediate

The LLM Returned Valid JSON — And Still Broke the Application

Three retries, same wrong shape: an object where a string belonged, inside a perfectly parseable response.

Read the investigation →
rag · advanced

The Database Had Vectors — But RAG Was Blind

Published docs, real 2048-d embeddings, and “no relevant knowledge found” for every question.

Read the investigation →
state machines · core

The Article Was Published — But the Agent Was Still Waiting

Regeneration creates multiple runs per post; finalization updated only one cached pointer.

Read the investigation →
ux · navigation

Login Worked — But Only After Refresh

Auth returned 200 with a fresh cookie; the client router just refused to believe it.

Read the investigation →
i18n · tricky

The Language Changed — Except the Language

Direction flipped instantly; server-rendered strings waited for a full request nobody made.

Read the investigation →
ui state · foundational

The Button Existed — But the Article Didn’t

A reserved placeholder post made “View Article” true from millisecond one of generation.

Read the investigation →
contracts · insidious

The Voice Settings Existed — But the System Didn’t Use Them

A full panel of provider/model fields the reply pipeline never read — while the real controls hid in plain sight.

Read the investigation →

AI Software Engineering Field Guide

Lessons from building, debugging and shipping AutoAI for Nature — an AI-native content platform with a nine-agent editorial pipeline, grounded RAG chatbot and voice assistant — from first commit to a frozen V1 in production.

Case-study driven · Every lesson traces to a real failure or fix · Context: Next.js · PostgreSQL/pgvector · OpenRouter (GPT-4o-mini + Nemotron embeddings) · Vercel

By Alireza Mokhtarabadi · AI Consultant & Software Engineer

Based on engineering lessons learned while designing, debugging, testing and deploying AutoAI for Nature. · Source: GitHub

01The Engineering Mindset

Most failed AI projects are not killed by models. They are killed by undefined scope, undefined "done", and endless polishing that replaces shipping.

Don't start by coding

AutoAI began with questions, not code: what happens when the pipeline finishes? Who decides an article is publishable? What if the model returns garbage? Answering those produced the review gate (waiting_for_human) and the mock-provider veto long before they were needed — which is why they held up under pressure.

Define what "done" means first

"Done" must be observable behavior. For AutoAI V1 it was written down explicitly:

  • A Persian or English topic produces a full article through nine agents and reaches waiting_for_human.
  • Human approval publishes → the article is indexed → immediately answerable by chat.
  • An off-topic question is refused — never answered from general model knowledge.

Every later "is this in scope?" argument was settled against those three lines.

V1 vs V2 is a survival decision

Near release we froze explicitly: no dashboard redesign, no pipeline animation work, no new voice providers, no RAG edge calibration. Not because those don't matter — each one reopens verified surface area. The last commit is literally titled fix: finalize V1 article and voice UX. Finishing is a feature.

How to know when to stop A project is done when its acceptance criteria pass in production — not when you run out of ideas. If your discovered-issue list grows faster than your fix list, you are polishing, not shipping. Write the V2 list down and close it with a straight face.

Deterministic state over assumptions

The most repeated mistake of the project: treating existence as readiness. A post row existed during generation → the UI showed "View Article". A knowledge document existed → everyone assumed retrieval worked. The cure was always the same: derive behavior from meaningful persisted state.

"The code works" ≠ "the product works." AutoAI passed hundreds of unit tests while production login hung on first click and RAG refused every question. Code correctness is one layer; product correctness is the whole path — browser → API → cookie → middleware → database → third-party API → back.

02Architecture Before Implementation

Architecture is deciding in advance where truth lives — so when reality disagrees with you, you know exactly which layer lied.

Sources of truth (AutoAI examples)

QuestionSingle source of truthWhat we stopped doing
Which model handles the strategist?Purpose config chain: override row → connection default → bootstrap → fallbackScattered DEFAULT_AI_* env reads per module
Is this document searchable?status='active' + allowed source_type + matching embedding identityAssuming "row exists"
Is the article viewable?postStatus + postHasContent read live from DBrun.postId ? show : hide
User language?autoai_locale cookie → messages + directionTreating RTL direction as the language state

Pre-code checklist

  1. Where does this data come from?
  2. Who owns it (which service may write it)?
  3. Where is it persisted, and what is its lifecycle?
  4. Who can change it after creation?
  5. How is it validated at every boundary?
  6. What happens when the external dependency fails?
  7. How will production observe it?
  8. How is it tested at each layer?
  9. How is it deployed, migrated, rolled back?

An unanswered item here becomes a production incident later.

Failure boundaries are architecture too

Draw them explicitly. AutoAI: provider failure ⇒ router tries primary, then fallback, then fails loudly (never silently degrades to mock). Publish-time indexing failure ⇒ surfaced in the API response and audit log. Grounding miss ⇒ deterministic refusal without calling the LLM at all. Every boundary drawn early became a place where failures were contained instead of spread.

03Configuration Is Part of the Architecture

AutoAI once shipped correct code that pointed all nine AI roles at a demo mock — because configuration resolution, not code, decided what ran.

The incident

Production threw, for every purpose:

AI is not configured for real generation:
idea: still points at the mock provider; strategist: still points at the mock provider; …

Cause: the purpose resolver fell back to process.env.DEFAULT_AI_PROVIDER || "mock", those variables didn't exist on Vercel, and the admin's real OpenRouter key lived in an entirely separate settings surface. Two configuration layers, zero precedence between them.

Lesson A system can contain correct code and still behave incorrectly because configuration resolution is wrong. Configuration is architecture — give it precedence, validation and failure behavior like any other component.

The precedence chain that fixed it

effective = 1. explicit per-purpose override   (Models page save)
            2. connection default               (Admin → AI Connections)
            3. documented bootstrap             (real credential + nothing configured)
            4. demo / mock                      (dev & tests only)

After this change, stale values could not defeat fresh ones, and a deployment holding only an environment key worked end-to-end without anyone re-typing secrets.

Five answers every dependency needs

QuestionAutoAI example
SourceDB row / env var / admin UI
Precedenceoverride > default > bootstrap > fallback
Validationpre-flight guard fails fast, naming every broken purpose
Fallbackdocumented and safe — never silent-mock in prod
Failure behaviorclear error listing exactly what misresolved

Mock is a test fixture, not a runtime option

The demo provider hard-fails outside automated tests via a guard only the test runner sets. That converted "accidentally shipped fake answers" from a maybe into an impossibility — while keeping mocks fully usable in unit tests.

04AI Providers and Model Abstraction

Providers are swappable infrastructure with per-model capabilities — and every model response is untrusted input until validated.

Model identity is more than a string

Every stored embedding carries {provider, model, dimensions}, and queries refuse to compare vectors unless the identity matches exactly. That one rule prevents silent corruption whenever models change: vectors from different models must never coexist or mix.

Capabilities are data

The live provider catalog exposes context length, pricing, supported parameters and structured-output support per model. The Models UI renders it; the request layer consults it. Never assume a model supports JSON-schema mode — ask, and degrade deliberately when it doesn't.

The structured-output incident

Schema validation failed at "outline.0": Expected string, received object
— after 3 attempts on openrouter/openai/gpt-4o-mini

The model kept answering {"outline":[{"angle":"…","audience":"…"}]} where the contract demanded outline: string[]. The prompt embedded only top-level keys as null hints and the request used bare json_object mode — nested shape was pure guesswork, and retries replayed the same guesswork.

Anti-pattern Treating successful JSON.parse as validation. An LLM response is not trustworthy merely because it is JSON.

The defense that works

  1. One canonical schema: Zod contracts compiled to JSON Schema by one utility; the same object goes to the provider AND restates expectations in the prompt.
  2. Native structured outputs: response_format: json_schema, strict when safe, with tiered downgrade json_schema → json_object → none.
  3. Strict local validation: Zod parses everything; failures quote exact paths (outline.0) into repair requests.
  4. Bounded repair loop: same provider/model, explicit "your output was invalid because…", hard attempt cap, then loud failure — never a silent template.
  5. Wire-level tests: stubbed fetch asserting actual request bodies — response_format type, strict flag, items.type === "string".

Fallback is policy

The router records every attempt (provider/model/latency/ok) and reports fallbackUsed. Fallback exists to survive outages — not to quietly swap a different, cheaper model whose outputs drift from everything you validated.

05RAG: Never Trust the Surface

The most important section. In AutoAI, retrieval failed in production while the database contained exactly what everyone assumed it needed: published documents and real 2048-dimensional Nemotron vectors.

The retrieval funnel

RAG is not one operation — it is a chain of filters, and any stage can silently reduce the result set to zero:

User question
Query embedding (provider / model / dimensions)
Candidate vectors in the store
Active documents only
Allowed source types
Embedding identity match (provider + model)
Cosine similarity ranking
Relevance threshold
Top-K chunks
Grounding decision
LLM response with citations
Core lesson Vectors existing in the database does NOT mean RAG works. Six independent mechanisms sit between the vector table and the user's answer, each able to return nothing.

Every way the funnel actually broke

StageReal failureHow we saw it
Status filterDocs created as drafts stayed inactive forever — even after their post was approved and published, because the update helper ignored status/sourceType fieldsDocuments visible in admin UI, absent from every search
Source typeSame docs stuck as draft_article; retrieval whitelisted only articleDirect column inspection
Embedding identityA whole identity group with (null, null, null) — created but never successfully indexed; plus any doc whose stored model string diverged from current configGROUP BY provider,model,dims over the corpus showed an unreachable bucket
Language filterUI locale restricted the corpus: English question + Persian-only corpus = zero candidates by constructionFunnel counts collapsed exactly at that stage; removed so the multilingual corpus is always searched and the answer language is handled separately
ThresholdCross-language similarity varies wildly per pair: measured 0.224–0.511 for genuinely related fa↔en content against a fixed 0.4 cutoffLive probes embedding real questions through the production model
Ingestion errorsPublish-time indexing failures swallowed by .catch(() => {}) — publish reported success while vectors never landedConfident metadata next to empty vector tables
Lying metadatachunk_count = 5 while zero rows existed in the chunks table (wiped by a failed full re-index)Join count between documents and chunks

Diagnose one stage at a time

The cure was instrumentation, not retrying. A diagnostic endpoint runs ONE real query and prints every stage:

{
  "stages": { "totalVectorsInKb": 9, "vectorsInActiveDocs": 9,
              "vectorsAfterSourceTypeFilter": 9,
              "vectorsMatchingQueryIdentity": 9 },
  "storedIdentities": [
    {"provider":"openrouter","model":"nvidia/nemotron…","dimensions":2048,"documents":2,"vectors":9},
    {"provider":"(null)","model":"(null)","dimensions":null,"documents":3,"vectors":0}
  ],
  "topSimilarities":[0.7344, 0.7048, 0.6962],
  "threshold": 0.4,
  "hasRelevant": true
}

The stage where a number collapses names the bug. That null-identity group is a smoking gun: three documents nobody could ever retrieve.

RAG debugging checklist

  1. Pick ONE known document. Confirm active status, correct source_type, correct post link.
  2. Count REAL vector rows for it (join the chunks table — never trust chunk_count metadata).
  3. Read stored embedding identity (provider/model/dimensions) off the document row.
  4. Embed the test question with the same configured identity; confirm dimensions.
  5. Run the funnel with per-stage counters; find where the candidate set collapses.
  6. If similarities exist but low: probe paraphrases AND an unrelated baseline question.
  7. Only now consider the threshold — recalibrate with measurements, never blind-lower it.
  8. Add a regression test for exactly the stage that lied.
Cross-language reality Multilingual embeddings are cross-lingual but unevenly. Same model measured: FA→EN twin document 0.224, FA→EN earthquake article 0.511, EN→FA 0.433. Pair-dependent variance means thresholds must be calibrated with real measurements in both directions — and corpus selection must never be coupled to UI locale.

06Grounding and AI Safety

A RAG chatbot becomes a generic chatbot by accident — one helpful completion at a time.

The contradiction

User asked: "Who is the current president of the United States?" The product claimed to be knowledge-grounded; the assistant answered from world knowledge anyway, appended "No relevant knowledge found", and both statements were true. It was not grounded — it merely suggested sources when retrieval happened to work.

Three decision layers

retrieval  → does relevant knowledge exist?   (measured)
policy     → is answering allowed?             (grounding guard)
generation → LLM sees ONLY permitted context    (strict system rules)

Rules that made grounding hold

  • Deterministic refusal before generation. Nothing relevant → refuse WITHOUT calling the model: fixed sentence, user's language, sources empty. No hallucination is possible when no token is generated.
  • Refusal normalization. If the model inside a grounded call returns the refusal sentence itself, normalize the exchange: clear sources, mark ungrounded, persist as refusal — otherwise fake attributions leak into history and metrics.
  • Refusals never become anchors. Follow-up context walks back to the last GROUNDED exchange (sources present, not a refusal). One off-topic detour must not redirect future topic anchoring.
  • Bounded follow-ups. Short questions ("و قبلش چی؟") re-retrieve as anchored-topic + follow-up text, still measured against the same threshold and still refusable.
  • Give the model a legal out: exact refusal sentence in the system rules, so "not in my knowledge base" has a deterministic form instead of improvised variants.
Acceptance triplet Test every grounding change with: on-topic question (must cite), unrelated question (must refuse with empty sources), anchored follow-up (must stay grounded). Run against real embeddings — mock similarity scales tell you nothing about production thresholds.

07Agent Pipelines Are State Machines

An agent pipeline is not "call AI several times". It is a stateful workflow with human gates, partial failures and multiple executions per entity.

Model it explicitly

AutoAI editorial flow: idea → strategist → researcher → writer → critic (+revision) → seo → publisher → final_critic → lessons, wrapped in run states queued → running → waiting_for_human → completed | failed | cancelled. Every step persists status, provider/model, latency, score, retries and error — the timeline is reconstructable from the database alone.

The stuck-run incident

Production: articles visibly published, yet Admin → Agent Runs listed their runs as waiting_for_human. First hypotheses were wrong (frontend cache? wrong run updated?). The truth:

approveArticle() finalized ONLY posts.agent_run_id.
But regeneration creates a NEW run for the SAME post
and re-points posts.agent_run_id to it.
⇒ older linked runs stayed waiting_for_human forever.
Lesson One entity can own MULTIPLE workflow executions — retries, regenerations, corrections. Finalization must sweep by foreign key (every waiting run for this post), never by a single cached pointer.

Rules extracted

  • Persist transitions the moment they happen; the database IS the state machine, the UI only reads it.
  • Human decisions are transitions: approval/rejection finalize all related executions.
  • Publish paths outside the review endpoint need identical finalization — hook at the shared service boundary, not per route.
  • Idempotency: sweeping zero runs is a valid outcome; running twice must be harmless.
  • Zombie running rows from crashed workers need grace-period reclaim so everything terminates.

08Database State vs Business State

"The row exists" and "the entity is valid" are different claims. Confusing them is how products lie.

Exists……but logically
post row during generationcontent empty — nothing viewable
knowledge document rowinactive → invisible to retrieval
document chunk_count = 5zero rows in chunks table — lying metadata
vectors presentidentity mismatched → unreachable by any query
agent run waiting_for_humanarticle already published — stale execution
voice settings panel renderedfields the backend never consumed

Making validity explicit

  • Treat source-type/status changes as business events (draft_article → article happens at publish), implemented in the data layer — not string cosmetics in the UI.
  • Expose readiness through the API from meaningful fields (postStatus, postHasContent) so clients cannot invent state.
  • Schedule integrity queries as health checks: orphans in every direction, stale executions, identity buckets. Orphan count zero is a metric, not a cleanup afterthought.

09Data Lifecycle

Content lives: create → draft → review → publish → index → update → re-index → delete. Related entities must move together through every transition.

Post ──▶ Agent Runs ──▶ Agent Steps
  └────▶ Knowledge Document ──▶ Chunks ──▶ Vectors

Publishing promotes the knowledge document (draft_article/inactive → article/active) and re-indexes it under the current embedding identity. Editing a published post re-syncs it. Deleting anything leaves zero orphans in both directions.

Cleanup is engineering, not janitorial work

The production demo-data purge ran as a reviewed operation:

  1. Dry-run first — print exact rows (id, title, status, createdAt).
  2. Resolve identifier prefixes to full UUIDs; assert exactly-one match each.
  3. Keep-list safety assertions: protected ids exist and appear in no delete set.
  4. Delete in FK-safe order: messages → conversations → chunks → documents → steps → runs → posts.
  5. No TRUNCATE. Ever.
  6. Post-verify: deleted gone, keep-list intact, orphan counts zero, survivor integrity (published article still had its 2 vectors / 2048 dims / metadata).
Never "Delete everything that looks like test data." Heuristic deletion against production destroys real content. Explicit IDs + dry-run + keep-list assertions — every single time.

10Migrations and Real Databases

Some columns are load-bearing in ways normal types are not. Vector dimensions are the canonical case.

Moving embeddings from 1536 to 2048 dimensions is not an ALTER COLUMN. Old vectors are permanently incompatible with new queries — mixing them corrupts every similarity search. The order is fixed:

accept old vectors are dead
→ wipe/rebuild dependent vector data
→ migrate schema  vector(1536) → vector(2048)
→ re-index EVERYTHING under the new model
→ verify identity metadata on every document

AutoAI ties the dimension to one constant used by both the column type and the runtime validator, with an explicit rule: vectors are never truncated or padded — mismatch fails loudly and demands full re-index.

Local ≠ proof PGlite passing locally cannot prove PostgreSQL behavior for extensions, planners, locks or concurrent writers. Verify critical database behavior against the real engine. And note: file-based local databases enforce single-writer assumptions your cross-process smoke tests will trip over.

11Local ≠ Production

local ≠ Vercel ≠ Neon ≠ OpenRouter. Every boundary adds failure modes your laptop never exercised.

  • Env defaults fire where variables are missing — which happens first in production (|| "mock" became nine broken purposes).
  • Auth rules differ by design: development accepts seed credentials; production rejects them and must not even display hints about them.
  • Cookies & middleware: client router caches and Set-Cookie timing behave differently behind edge deployments (the first-login hang).
  • Runtime concurrency: file-based local databases are single-writer — cross-process smoke scripts silently see a different world than the app process.
  • Data drift: production accumulates states local never reproduced (null identities, mixed languages, stale runs).
  • Provider catalogs drift: free models vanish, capability flags change; hardcoding yesterday's catalog is a future outage.
Production proof A feature is finished when the ACTUAL deployed path passes: real URL, real session, real database, real keys — observed via network tab or diagnostics endpoints. Until then the honest status is "works locally".

12Debugging: Find the First Divergence

The discipline that ended AutoAI's longest debug loops — including two separate "impossible" production bugs.

Expected behavior   → write it down first
Observed behavior   → capture real evidence
Stages              → decompose the path into measurable steps
Measure each stage  → counters, values, statuses
FIRST divergence    → earliest stage where expected ≠ observed
Fix root cause      → that stage only
Regression test     → lock the exact case
Production verify   → prove it on the real path

Showcase 1: the RAG funnel

"Chat returns no knowledge for everything" decomposed into the §05 funnel. Per-stage counters immediately separated three distinct incidents: identity mismatch (null buckets), status/source-type staleness, and language-coupled corpora. Three root causes, one diagnostic endpoint, zero random code changes.

Showcase 2: the login hang

form → POST /api/auth/login → 200 + Set-Cookie ✓
     → router.push("/admin") ✗ client cache held the UNAUTHENTICATED payload
     → router.refresh() raced/cancelled the push
     → spinner never resolves
manual refresh → cache warm with fresh cookie → next click succeeds

The network tab proved authentication succeeded; measurement localized the divergence to the navigation stage. The fix (full-document navigation so middleware always sees the fresh cookie) was three lines. Guessing "session bug" would have rewritten working auth.

Why it prevents endless loops Random fixing changes code; measurement changes understanding. Once the first divergence is named, everything upstream is exonerated and everything downstream is irrelevant.

13Testing AI Systems

AutoAI grew from 49 to 83 tests across these fixes. But counts are trivia; layers are the point.

LayerProvesAutoAI example
UnitPure decision logicconfig-precedence matrix · view-article gate · refusal thresholds
ContractPublic shapes stay honestvoice settings expose exactly 3 real fields
RegressionThe historical bug never returnsoutline[0] object rejection · i18n key parity
Wire-levelActual HTTP contractsstubbed fetch asserting response_format.json_schema.strict + item types
Real providerThe model behavesPersian/English structured outputs vs live gpt-4o-mini
Real databaseStorage behavespgvector dims, orphan sweeps, funnel counters on Neon
Production smokeDeployed path workshealth + authenticated diagnostics on the live URL
Hard truth Mock PASS proves logic. OpenRouter PASS proves product. The structured-output bug passed every mock test while failing every real request — because the mock fills keys by name instead of negotiating schemas the way a model does.

Also true in reverse: a wire-level test asserting request shape would have caught the bug before any deployment, without spending a single token. Layered tests exist so each layer catches what the previous one structurally cannot.

14Security and Secrets

  • Keys live server-side. Provider calls execute in backend routes; browsers receive sanitized catalogs and booleans (hasKey: true). Verified by scanning every admin response body for key fragments.
  • Secret-scan every commit. Patterns for key prefixes, connection strings, private-key headers, passwords over the staged diff. Eight consecutive pushes, zero leaks — because the scan ran every time.
  • Temporary injection pattern. Production DB cleanup used a credential staged in an OS temp file, read into env per command, deleted immediately after. Never echoed, never committed.
  • Dev-only messaging is surface area. "Default credentials are set in .env.local" shipped unconditionally on the production login page — inside translation JSON, invisible to component review. Removed at both layers, enforced by a test grepping message files AND the built bundle.
  • Demo credentials are production-rejected by design, and bootstrap requires explicitly configured environment values.
  • Auth hardening stack: open-redirect guard on post-login targets, httpOnly+SameSite cookies, middleware-guarded admin APIs, AUTH_SECRET length enforced at boot, session tokens treated as revocable secrets.
Near-miss worth naming A session token pasted for debugging was staged to a temp file like a credential and deleted after use. Treat EVERY authenticator — keys, cookies, connection strings — with the same lifecycle: inject → use → destroy → verify destroyed.

15I18N Is Not Just Translation

Production report: switching to Persian left English strings everywhere; switching back flipped only direction while text stayed Persian.

The contract

locale ("fa" | "en")
   ├─▶ messages[locale]
   └─▶ dir = rtl | ltr        (derived — NEVER the language state)

What actually broke

The switcher wrote the cookie and updated client dictionaries — but server-rendered components had already been generated under the old cookie. Direction flipped instantly (client effect); strings lagged until some later full request. Half-translated pages that looked like a rendering bug were a refresh-boundary bug.

The fix set

  • Locale switch triggers router.refresh() so ALL server components re-render under the new cookie.
  • Persistence via one year-long cookie read identically by layout, server i18n helper and client provider.
  • Key-parity regression tests: EN key set must equal FA key set (633 = 633), no empty values.
  • Direction always derived (isRTL(locale)) on both server render and client effect.
Subtle trap Direction is presentation; locale is data. Any code inferring language FROM dir — or setting dir as if choosing a language — couples two systems that must evolve independently.

16UI Should Reflect Real System State

During generation, users clicked "View Article" into an empty shell. The guard was run.postId != null — true from the moment the pipeline reserved a placeholder post.

ID exists……≠ meaningful state
post.idpost has viewable content
knowledge_document.iddocument searchable
agent_run.idrun currently executing
voice panel renderedsettings actually consumed by backend

Fix pattern: expose REAL state through the API (postStatus, postHasContent read live from DB), gate through a pure function, cover with a matrix test (7 cases: empty draft hidden during queued/running/failed; published/needs_review/saved-draft visible).

Rule UI actions derive from meaningful business state delivered by the backend — never from identifier existence, timers, or client-side guesses about async progress.

The same principle caught the voice settings panel: it rendered inputs for fields the implementation never read (STT/TTS providers, LLM model pickers) while omitting what it DID consume. The rewrite exposed a live capability status (browser Web Speech API, supported languages, resolved engine) plus only the three genuinely configurable fields — verified by a contract test asserting the exact exposed key set.

17Scope Control and V1/V2

Not every discovered defect belongs to the current release. AutoAI's final phase survived because scope was locked in writing — forbidden files listed by name.

ClassDefinitionReal example
P0Blocks core workflow / correctness / securitypurposes→mock; RAG returning nothing; first-login hang
P1Important, contained fixlanguage-filtered retrieval; honest voice panel
P2Polishpipeline animation, dashboard layout, loading micro-states
V2Future capabilitynew voice providers, image system, threshold calibration research

Five-question gate for any new issue

  1. Does it block the core workflow?
  2. Does it break correctness?
  3. Does it create security risk?
  4. Does it prevent demonstrating the product?
  5. Does it require architectural change?

Any YES → now. All NO → V2 backlog, written down, closed with a straight face.

Endless polish kills Every "small improvement" to finished code re-opens verified surface and spends trust you just built. AutoAI's freeze memo named forbidden files explicitly. That memo is why the release happened.

18The Complete AI Project Checklist

Walk this before writing code — and again before shipping. Click items as you complete them.

Phase 0 — Define

  • Problem statement (one paragraph)
  • User + primary workflow
  • Success criteria (observable, testable)
  • V1 scope written; V2 backlog written and closed

Phase 1 — Architecture

  • Sources of truth per domain
  • Data lifecycle diagram
  • State machine(s) incl. human gates
  • External dependencies + failure boundaries
  • Configuration precedence designed

Phase 2 — AI

  • Provider abstraction behind one interface
  • Per-model capability verification (live catalog)
  • Structured-output strategy + strict validation
  • Embedding model + frozen dimensions + provenance storage
  • Fallback policy (never silent-degrade)
  • Mock strategy (test-only, guarded)

Phase 3 — Data

  • Schema + constraints + indexes
  • Vector dimensions tied to one constant
  • Re-index strategy for model/dimension changes
  • Cleanup tooling: dry-run, keep-list, orphan checks

Phase 4 — RAG

  • Ingestion + chunking parameters
  • Publish/update/delete hooks guarantee index sync
  • Metadata: source type, status, identity, timestamps
  • Retrieval funnel instrumented per stage
  • Threshold calibrated on real measurements
  • Grounding policy + deterministic refusal + follow-up anchors

Phase 5 — Agents

  • Explicit persisted state machine
  • Step tracking with provider/model/retries
  • Multiple executions per entity supported
  • Human gate + terminal-state sweep
  • Zombie-run reclaim

Phase 6 — Security

  • Secrets server-side; scan every commit
  • AuthN/AuthZ middleware guards
  • Production guards against demo/mock paths
  • No dev-only messaging in prod bundles

Phase 7 — Testing

  • Unit tests for pure decisions
  • Contract tests for public shapes
  • Wire-level provider tests
  • Real-provider + real-database suites
  • Production smoke checklist

Phase 8 — Deployment

  • All env vars enumerated + validated at boot
  • Migrations ordered incl. destructive/vector cases
  • Build green; runtime smoke on real URL
  • Diagnostics endpoints + logs reachable

Phase 9 — Ship

  • V1 scope respected (freeze memo honored)
  • Known issues documented as V2
  • Rollback strategy stated
  • Demo rehearsed against production data
  • Final QA = acceptance criteria ticked

19The 10 Rules I Wish I Knew Before Starting

1. If you cannot define "done", you are not ready to code.Undefined done = infinite polish = unshipped project.
2. JSON is not validation.A parseable model response can still be structurally wrong. Validate schemas locally, always.
3. A vector in a database does not mean RAG works.Identity, status, source-type, language and threshold all stand between the vector and the answer.
4. IDs are not business state.An existing row is not a ready entity. Gate UI on meaning, not pointers.
5. Mock success is not production success.Mocks prove logic; only real providers prove products — on the wire and live.
6. Find the first divergence, not the most suspicious code.Instrument stages and let evidence name the guilty layer.
7. Every workflow needs an explicit persisted state machine.Especially with humans in the loop and multiple executions per entity.
8. Production is another environment, not just another URL.Env resolution, caches, auth rules and data drift are waiting there.
9. Not every problem belongs in V1.Write the V2 list down and close it deliberately.
10. Done = acceptance criteria pass in production.Not out-of-ideas, not tired — ticked boxes on the real path.

20Case Studies — What Actually Happened

Seven production incidents, compressed. Expand for symptom → wrong assumption → investigation → root cause → fix → transferable lesson.

Case 1 — LLM structured output failure

Symptom

Schema validation failed at "outline.0": Expected string, received object after 3 attempts on gpt-4o-mini — intermittent, real traffic only.

Wrong assumption

"We send the schema, so the model follows it." The 'schema' was top-level keys mapped to null; nested shapes were never communicated, and retries replayed identical ambiguity.

Investigation

Reproduced live; captured actual request bodies; found bare json_object mode and null-hint prompts; confirmed the repair loop restated the same ambiguous hint.

Root cause

Provider contract ≠ application contract. No native structured output; nested types existed nowhere outside Zod.

Fix

One Zod→JSON-Schema compiler feeding native json_schema mode (strict when safe) AND the prompt; tiered downgrade json_schema→json_object→none; strict local validation quoting paths into bounded repairs; wire-level regression tests asserting items.type=="string".

Lesson

JSON is not validation. One canonical schema must feed provider, prompt, parser and validator — anything less invites drift between what you expect and what you asked for.

Case 2 — RAG vectors existed but retrieval failed

Symptom

"No relevant knowledge" for every question while Neon held published documents and real 2048-d Nemotron vectors.

Wrong assumption

"Vectors exist ⇒ retrieval works."

Investigation

Stage-by-stage funnel diagnosis: total vectors → active-docs → source-type → identity-match → similarities → threshold. Also direct probes embedding real questions through the production model.

Root cause

Multiple stacked causes across incidents: documents stuck draft_article/inactive; a publish-time update helper silently ignoring those columns; null embedding-identity rows unreachable by any query; swallowed indexing errors faking success; a UI-language filter zeroing cross-language corpora.

Fix

Canonical promotion at publish; single authoritative indexer with loud failures; per-stage diagnostics endpoint; removal of locale-coupled corpus filtering; integrity checks (orphans, identity groups).

Lesson

Never debug RAG as a unit. Instrument the funnel; the collapsing stage names the bug — and "vectors exist" is never evidence.

Case 3 — Published article stayed waiting_for_human

Symptom

Admin → Agent Runs showed runs awaiting approval for articles that were already publicly published.

Wrong assumption

"Frontend shows stale data" / "the wrong run got updated." Both false.

Investigation

Traced approval flow: finalize logic updated only posts.agent_run_id. But regeneration inserts a NEW run per post and re-points that column — older linked runs were orphaned in waiting state forever.

Root cause

One-entity-one-execution assumption inside a multi-execution workflow; plus publish paths outside the review API never finalized anything.

Fix

finalizeWaitingRunsForPost(postId) sweeping every waiting_for_human run by foreign key; wired into approve, reject AND the shared status-transition service; idempotent; covered by matrix regression tests.

Lesson

Workflows terminate by relationship sweeps, not pointer updates — and every path that changes business state must trigger the same terminal transitions.

Case 4 — Login worked only after refresh

Symptom

Valid credentials + click → infinite spinner. Refresh → click again → success.

Wrong assumption

"Session bug" / "middleware rejects first cookie." Network tab disproved both: the login POST returned 200 with Set-Cookie on the FIRST attempt.

Investigation

Stage-by-stage: auth ✓, cookie ✓, navigation ✗ — client router cache still held the unauthenticated /admin payload, and router.refresh() raced router.push(), leaving the spinner mounted.

Root cause

Client-side navigation racing its own cache after an auth-state change that only the server had seen.

Fix

Full-document navigation post-login (window.location.assign to a sanitized internal target) so middleware always evaluates the fresh cookie; spinner resolves by page transition; open-redirect guard added.

Lesson

Auth-state changes demand full navigations or explicit cache invalidation. Client caches do not know your cookie changed.

Case 5 — Language switched direction but not content

Symptom

FA↔EN toggle flipped RTL/LTR instantly while most text remained in the previous language; other pages mixed languages entirely.

Wrong assumption

"Translation files are missing keys." Parity check showed 628=628 keys, zero gaps.

Investigation

Compared client-side dictionary updates (instant) against server-rendered sections (stale until full request). The switcher updated cookie + client state but never re-rendered server components.

Root cause

Two render sources (client context + server components) sharing one cookie, with the switcher updating only one side.

Fix

setLocale triggers router.refresh(); single cookie contract read identically by layout/server-i18n/client provider; parity + script-change regression tests added.

Lesson

Locale ≠ direction, and hybrid render trees need ONE transition that refreshes BOTH sides. Also: audit bundles — a dev-only credential hint had shipped to production inside translations.

Case 6 — View Article appeared before content existed

Symptom

During generation, "View Article" opened an empty shell.

Wrong assumption

"postId exists means there's something to see." The pipeline reserves an empty placeholder post up-front, so postId was true from millisecond one.

Investigation

Inspected what the API actually exposed (nothing about readiness) versus what the DB contained (status='draft', empty content).

Root cause

UI action gated on identifier existence instead of business state.

Fix

API exposes postStatus+postHasContent read live; pure canViewArticle() gate with a 7-case matrix test; verified end-to-end by flipping a published article to empty-draft and back through the admin API — gate hid, then restored with content intact.

Lesson

Gate actions on business state delivered by the backend. IDs are pointers, not predicates.

Case 7 — Voice settings appeared empty/fake

Symptom

Voice Agent settings blank when no config row existed; when present, full of provider/model fields the runtime ignored.

Wrong assumption

"The form just needs wiring up." Reading the service showed which fields the reply pipeline ACTUALLY consumes: ragEnabled, systemPrompt, temperature — nothing else.

Investigation

Grepped every consumer of the config object; confirmed STT/TTS run entirely in-browser via Web Speech API and the LLM resolves per-purpose from model configuration.

Root cause

UI mirrored a hypothetical architecture (external STT/TTS + selectable models) instead of the real one (browser APIs + purpose-routed LLM). Plus null-config rendered literally nothing.

Fix

Honest panel: capability status (browser Web Speech, languages EN/FA, live resolved engine) + exactly the three real controls; whitelist PATCH; contract test asserting the exposed key set equals the consumed set; defaults so the panel never blanks.

Lesson

Settings UIs must expose the intersection of what users can change and what the system reads. Everything else is fiction that erodes trust — and a contract test can enforce honesty forever.

Closing note None of these seven failures involved exotic technology. Every one traces to a boundary drawn too late: config precedence, render boundaries, state-machine sweeps, cache invalidation, corpus filters, ID-based gating, UI/backend contract drift. Draw the boundaries early — or meet them in production.
Back to Portfolio View AutoAI for Nature on GitHub