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?”
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?”
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?”
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?”
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?”
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?”
§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.
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.
What does “done” mean — observably?
What is explicitly V1?
What is explicitly V2?
What is the source of truth for each piece of state?
Where is the state machine — including human gates?
What happens when the AI returns malformed data?
What happens when the provider is unavailable?
How are embeddings identified (provider/model/dimensions)?
How will I PROVE RAG works — stage by stage?
How do I prove production works, not just local?
What is my rollback strategy?
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.
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
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.
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.
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
Question
AutoAI example
Source
DB row / env var / admin UI
Precedence
override > default > bootstrap > fallback
Validation
pre-flight guard fails fast, naming every broken purpose
Fallback
documented and safe — never silent-mock in prod
Failure behavior
clear 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
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.
Native structured outputs:response_format: json_schema, strict when safe, with tiered downgrade json_schema → json_object → none.
Strict local validation: Zod parses everything; failures quote exact paths (outline.0) into repair requests.
Bounded repair loop: same provider/model, explicit "your output was invalid because…", hard attempt cap, then loud failure — never a silent template.
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
Stage
Real failure
How we saw it
Status filter
Docs created as drafts stayed inactive forever — even after their post was approved and published, because the update helper ignored status/sourceType fields
Documents visible in admin UI, absent from every search
Source type
Same docs stuck as draft_article; retrieval whitelisted only article
Direct column inspection
Embedding identity
A whole identity group with (null, null, null) — created but never successfully indexed; plus any doc whose stored model string diverged from current config
GROUP BY provider,model,dims over the corpus showed an unreachable bucket
Language filter
UI locale restricted the corpus: English question + Persian-only corpus = zero candidates by construction
Funnel counts collapsed exactly at that stage; removed so the multilingual corpus is always searched and the answer language is handled separately
Threshold
Cross-language similarity varies wildly per pair: measured 0.224–0.511 for genuinely related fa↔en content against a fixed 0.4 cutoff
Live probes embedding real questions through the production model
Ingestion errors
Publish-time indexing failures swallowed by .catch(() => {}) — publish reported success while vectors never landed
Confident metadata next to empty vector tables
Lying metadata
chunk_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:
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
Pick ONE known document. Confirm active status, correct source_type, correct post link.
Count REAL vector rows for it (join the chunks table — never trust chunk_count metadata).
Read stored embedding identity (provider/model/dimensions) off the document row.
Embed the test question with the same configured identity; confirm dimensions.
Run the funnel with per-stage counters; find where the candidate set collapses.
If similarities exist but low: probe paraphrases AND an unrelated baseline question.
Only now consider the threshold — recalibrate with measurements, never blind-lower it.
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 generation
content empty — nothing viewable
knowledge document row
inactive → invisible to retrieval
document chunk_count = 5
zero rows in chunks table — lying metadata
vectors present
identity mismatched → unreachable by any query
agent run waiting_for_human
article already published — stale execution
voice settings panel rendered
fields 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.
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:
Dry-run first — print exact rows (id, title, status, createdAt).
Resolve identifier prefixes to full UUIDs; assert exactly-one match each.
Keep-list safety assertions: protected ids exist and appear in no delete set.
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.
Persian/English structured outputs vs live gpt-4o-mini
Real database
Storage behaves
pgvector dims, orphan sweeps, funnel counters on Neon
Production smoke
Deployed path works
health + 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.id
post has viewable content
knowledge_document.id
document searchable
agent_run.id
run currently executing
voice panel rendered
settings 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.
Class
Definition
Real example
P0
Blocks core workflow / correctness / security
purposes→mock; RAG returning nothing; first-login hang
new voice providers, image system, threshold calibration research
Five-question gate for any new issue
Does it block the core workflow?
Does it break correctness?
Does it create security risk?
Does it prevent demonstrating the product?
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
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.
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.
"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.