DEV Community

Cover image for Five Invariants for a Stateful AI Chat Backend
Aihh
Aihh

Posted on

Five Invariants for a Stateful AI Chat Backend

A stateless chat demo is forgiving. Send an array of messages to a model, receive text, and render it.

A stateful chat backend is not.

As soon as you add persistent conversations, extracted facts, summaries, retries, streaming, or multiple workers, the hardest bugs stop looking like syntax errors. They look like plausible dialogue:

  • the current user message appears twice in the prompt;
  • an old fact survives after a correction;
  • a retry creates two assistant messages;
  • a summary claims an event that never happened;
  • a long message silently removes the wrong part of history.

The safest way to build this system is to define invariants before tuning prompts.

Five backend invariants around a stateful AI chat request

This article describes five that can be tested without depending on a specific model provider.

A minimal request model

Assume one turn passes through this pipeline:

accept user message
  -> establish turn boundary
  -> retrieve eligible history and memories
  -> build prompt
  -> call model
  -> persist assistant result
  -> update derived memory asynchronously
Enter fullscreen mode Exit fullscreen mode

One stateful chat turn from accepting a message through provenance-preserving memory updates

The word eligible is important. Persistent data is not automatically prompt data. Each record still needs scope, freshness, precedence, and a budget.

We will use these simplified TypeScript types:

type ChatMessage = {
  id: string
  conversationId: string
  role: "user" | "assistant"
  content: string
  createdAt: Date
}

type MemoryFact = {
  id: string
  key: string
  value: string
  observedAt: Date
  supersedes?: string
  confidence: number
}

type Turn = {
  id: string
  conversationId: string
  startedAt: Date
  userMessageId: string
}
Enter fullscreen mode Exit fullscreen mode

The exact database does not matter. The invariants do.

Invariant 1: the current turn cannot re-enter history

Consider this race:

  1. the API saves the current user message;
  2. a history query runs immediately afterward;
  3. the prompt builder appends the current message again as the active request.

The model sees the same input twice. It may answer with repetition, overemphasize one instruction, or behave as if the user insisted.

The history query needs an explicit boundary:

async function loadHistory(
  conversationId: string,
  before: Date,
): Promise<ChatMessage[]> {
  return db.message.findMany({
    where: {
      conversationId,
      createdAt: { lt: before },
    },
    orderBy: { createdAt: "asc" },
  })
}
Enter fullscreen mode Exit fullscreen mode

Capture startedAt before concurrent work can blur the boundary. An even stronger design excludes the current userMessageId as well, because timestamps can collide or be normalized by a database.

Test it as a property:

expect(prompt.messageIds.filter(id => id === turn.userMessageId))
  .toHaveLength(1)
Enter fullscreen mode Exit fullscreen mode

Exactly once is the invariant—not “usually once under local timing.”

Invariant 2: a correction must dominate the fact it replaces

Appending every extracted fact creates a memory pile, not a memory system.

If the user says:

The plant is named Harbor.
Enter fullscreen mode Exit fullscreen mode

and later says:

I renamed it Marlowe. Do not call it Harbor anymore.
Enter fullscreen mode Exit fullscreen mode

retrieval must not return both names as equally valid facts.

One option is a versioned fact stream:

const facts: MemoryFact[] = [
  {
    id: "f1",
    key: "plant.name",
    value: "Harbor",
    observedAt: new Date("2026-07-01"),
    confidence: 0.9,
  },
  {
    id: "f2",
    key: "plant.name",
    value: "Marlowe",
    observedAt: new Date("2026-07-02"),
    supersedes: "f1",
    confidence: 1,
  },
]
Enter fullscreen mode Exit fullscreen mode

Resolve precedence before prompt assembly:

function activeFacts(items: MemoryFact[]): MemoryFact[] {
  const superseded = new Set(
    items.flatMap(item => item.supersedes ? [item.supersedes] : []),
  )

  return items.filter(item => !superseded.has(item.id))
}
Enter fullscreen mode Exit fullscreen mode

In production you may use unique keys, tombstones, validity windows, or a merge function. Whatever the representation, test the semantic rule:

expect(active.map(f => `${f.key}=${f.value}`)).toEqual([
  "plant.name=Marlowe",
])
Enter fullscreen mode Exit fullscreen mode

“The prompt contains the new fact” is insufficient. It must also exclude the obsolete one.

Invariant 3: context trimming is deterministic and keeps the newest valid history

Counting messages is not a context budget.

Ten short messages may be smaller than one pasted document. If you retrieve the latest N rows and stop there, one oversized row can still break the model request.

Use two stages:

  1. a database limit to avoid loading the entire conversation;
  2. a token or character budget that walks backward from the newest message.
type SizedMessage = ChatMessage & { estimatedTokens: number }

function keepNewestWithinBudget(
  newestFirst: SizedMessage[],
  maxTokens: number,
): SizedMessage[] {
  const kept: SizedMessage[] = []
  let used = 0

  for (const message of newestFirst) {
    if (used + message.estimatedTokens > maxTokens) continue
    kept.push(message)
    used += message.estimatedTokens
  }

  return kept.reverse()
}
Enter fullscreen mode Exit fullscreen mode

This sample skips messages that do not fit. Another policy may stop at the first overflow to preserve a contiguous window. Choose deliberately and test the exact rule.

Required tests include:

  • one message larger than the entire budget;
  • many tiny messages;
  • two messages with the same timestamp;
  • Unicode and emoji-heavy content;
  • tool results or code blocks with unusual size estimates;
  • an empty conversation.

The invariant is that the same stored state and budget produce the same ordered prompt context.

Invariant 4: retrying a turn cannot create a second committed answer

Networks fail after work succeeds.

The model may return an answer, the server may persist it, and the client may time out before receiving confirmation. A retry then arrives with the same logical turn.

If “retry” means “run everything again,” users can receive two answers and be charged twice.

Give each turn an idempotency key and commit once:

async function commitAssistantMessage(input: {
  turnId: string
  conversationId: string
  content: string
}) {
  return db.assistantMessage.upsert({
    where: { turnId: input.turnId },
    create: input,
    update: {},
  })
}
Enter fullscreen mode Exit fullscreen mode

The database constraint is doing more safety work than an in-memory “already running” flag. Process restarts and multiple workers make local flags unreliable.

Test at the concurrency boundary:

const results = await Promise.all(
  Array.from({ length: 5 }, () => commitAssistantMessage(input)),
)

expect(new Set(results.map(r => r.id)).size).toBe(1)
Enter fullscreen mode Exit fullscreen mode

If the system reserves credits or dispatches tools, use the same logical turn or task identifier across those ledgers as well.

Invariant 5: missing evidence cannot become remembered history

Summaries are derived data. They are not automatically true.

A model-generated summary might say “the user sent the letter” because sending it was discussed repeatedly, even though the user explicitly decided not to send it.

Store provenance with durable memory:

type DurableMemory = {
  key: string
  value: string
  sourceMessageIds: string[]
  extractionMethod: "rule" | "model" | "user-confirmed"
  confidence: number
}
Enter fullscreen mode Exit fullscreen mode

Before injecting a strong claim, require supporting messages and apply a confidence threshold appropriate to the consequence.

function canInject(memory: DurableMemory): boolean {
  if (memory.extractionMethod === "user-confirmed") return true
  return memory.sourceMessageIds.length > 0 && memory.confidence >= 0.85
}
Enter fullscreen mode Exit fullscreen mode

For uncertain or conflicting evidence, the generated response should ask or qualify:

I remember that you were working on the letter, but I am not sure whether you sent it.
Enter fullscreen mode Exit fullscreen mode

That is not a conversational weakness. It is correct uncertainty behavior.

Test the prompt plan, not only the final prose

End-to-end model assertions are useful, but they are noisy. A model can accidentally produce the right answer from a wrong prompt.

Expose a debug representation of the prompt plan in tests:

type PromptPlan = {
  historyMessageIds: string[]
  activeFactIds: string[]
  excluded: Array<{
    id: string
    reason: "superseded" | "expired" | "budget" | "low-confidence"
  }>
  estimatedTokens: number
}
Enter fullscreen mode Exit fullscreen mode

Then assert structural facts:

  • current turn appears exactly once;
  • superseded memory is excluded for the right reason;
  • token estimate stays below the configured budget;
  • retries resolve to one committed result;
  • claims without provenance never enter the strong-memory section.

These tests remain stable when you change the model, prompt wording, or provider.

Observability should name the invariant that failed

“Bad AI response” is not an actionable log message.

Record enough metadata to answer:

  • which turn boundary was used;
  • how many history rows were loaded and trimmed;
  • which memories were included or excluded;
  • the budget before and after assembly;
  • the idempotency key and commit result;
  • whether a memory claim had provenance;
  • the trace identifier across API, worker, and database operations.

Do not log private message bodies by default. Identifiers, counts, reasons, and hashes are often enough to diagnose pipeline behavior without expanding access to sensitive conversations.

A compact test matrix

Scenario Expected invariant
Save and retrieve race Current message appears once
User corrects a stored fact Only the replacement is active
One huge recent message Prompt stays within budget
Five concurrent retries One assistant result is committed
Summary invents an outcome Unsupported claim is excluded or qualified
Worker restarts after persistence Retry returns the existing result
Conflicting facts have equal timestamps Tie-breaking remains deterministic

Run the matrix against the prompt planner and storage layer before involving a live model. Then add a smaller set of model-level evaluations for tone and uncertainty.

The practical takeaway

Stateful AI chat quality is not only a model-quality problem.

It is a data-consistency problem with a model at the end.

If the backend supplies duplicated input, stale facts, nondeterministic history, repeated jobs, or unsupported summaries, a fluent model will hide the bug just long enough to make it expensive.

Define the invariants. Make exclusions observable. Test concurrency. Preserve provenance. Let uncertainty remain uncertainty.


Affiliation and AI-assistance disclosure: I work with the LumiChat team, where stateful character conversations are one of the engineering problems we study. This article presents a vendor-neutral backend testing approach rather than a product recommendation. AI tools assisted with editing and structure; the examples, invariants, and final claims were reviewed by the author.

Top comments (0)