A VIP user on HoneyChat opens a chat session, picks Standard from the model dropdown to downgrade from our default of Premium (their plan would normally route to a slower, higher-quality model — they wanted a snappier response for one scene). The dropdown’s underlying value for “Standard” happens to be the empty string — "" — because in our config that means “no per-session override, fall through to whatever the plan’s tier-default routing says.”
Except it didn’t. The user got Premium anyway, billed against their plan, on a scene where they explicitly didn’t want it. After two more reports we found the cause.
The bug was the same on three layers. The fix was eight lines.
HoneyChat LLM routing context (core/llm.py):
| Setting | Layer 1: session | Layer 2: profile | Layer 3: plan default |
|---|---|---|---|
model_id | per-chat dropdown override | per-user preference | tier-derived (qwen3-235b-a22b-2507 for premium-natural, deepseek-v4-flash for instant, gemini-3.1-flash-lite-preview for vip/elite) |
pace | per-chat dropdown override | per-user preference | natural |
style | per-chat dropdown override | per-user preference | tier-derived |
Each layer can express “don’t touch this, use the next layer.” In Python the natural way to do that is None. But our database has been around longer than that decision, and several columns store "" for the same thing — the front-end dropdowns serialize empty option as "", and we never migrated the schema. So the resolver had to handle both.
The original resolve_llm_settings() looked like this:
# core/llm.py — original (buggy) implementationdef resolve_llm_settings(session, profile, plan): model = session.model_id or profile.model_id or DEFAULT_FOR_PLAN[plan] pace = session.pace or profile.pace or "natural" style = session.style or profile.style or DEFAULT_STYLE return model, pace, styleRead it left to right. If session.model_id is “truthy,” use it. Otherwise try profile.model_id. Otherwise the plan default.
The problem: "" or "qwen3-235b-a22b-2507" evaluates to "qwen3-235b-a22b-2507". An empty string is falsy in Python, the same as None. So when our user explicitly picked the empty-string option (“don’t override, use plan default”) and their profile preference was set to Premium, the or chain skipped right past their explicit session choice and fell through to the profile, giving them Premium when they wanted Standard.
This is the same behavior in JavaScript:
const model = session.model_id || profile.model_id || DEFAULT_FOR_PLAN[plan];"" || "qwen3-235b-a22b-2507" is "qwen3-235b-a22b-2507" in JS too. Same bug.
We had this exact mistake in three files at once:
core/llm.py::resolve_llm_settings()— server-side, runs on every chat call. Determines what model OpenRouter actually gets hit with.web/src/components/ChatRoom.tsx— picks which model name to display in the chat header (the user’s only visual confirmation of what’s running).miniapp/src/components/CharDetail.tsx— same logic on the Mini App’s character screen.
Three different authors, three different release cycles, all using the same intuitive || chain. The same logic for pace had the same bug — session.pace="" (explicit “use plan default”) fell through to a profile setting of instant.
The fix: membership, not truthiness
The right check is “did the layer say something”, not “is the value truthy”. In Python that’s in against the dict:
# core/llm.py — fixed (2026-05-18)def resolve_llm_settings(session, profile, plan): # session is a dict from the DB row; missing keys = layer didn't say if "model_id" in session: model = session["model_id"] # may be "" — that's a valid choice elif "model_id" in profile: model = profile["model_id"] else: model = DEFAULT_FOR_PLAN[plan] # …same shape for pace and style… return model, pace, styleIn TypeScript, the equivalent is ?? (nullish coalescing) — it falls through only on null or undefined, so an empty string passes through correctly:
// web/src/components/ChatRoom.tsx + miniapp/src/components/CharDetail.tsx — fixedconst model = session.model_id ?? profile.model_id ?? DEFAULT_FOR_PLAN[plan];?? is the idiomatic fix in modern JS/TS. The !== undefined chain is what you reach for if you also need to distinguish null from “value is the empty string.”
The Python in check is the analogous fix to ??. It asks “did this layer carry a key for this setting?” instead of “is the value at this key truthy?”
How we found all three places
The user reported the model name was wrong on the web chat header (TypeScript layer 2). Fixing it there made the UI show the right name. The actual billing still ran the wrong model — because the server-side Python resolver had its own copy of the same bug (layer 1). Two days later a different user reported the same symptom in the Mini App character screen (layer 3).
After the third report we did a grep audit:
# Python — find chains of `or` between dict accessesrg 'session\.[a-z_]+\s*or\s+profile' --type py
# TypeScript — find chains of `||` between object accessesrg 'session\.[a-z_]+\s*\|\|\s*profile' --type tsFound seven more places following the same pattern. Five were latent bugs waiting for the right user input to surface (mostly in web_chat.py for pace/style and in bot/handlers/chat.py for the same fields on the Telegram path). Two were intentional (the underlying field genuinely never stored the empty string, so || was fine). We replaced the five with ?? / in checks.
We also wrote a follow-up decision (internal D-profile-prefs-prefill-only-2026-05-19): for the profile layer specifically, the resolver now treats it as UI pre-fill only — runtime never reads it. Resolver chain became session → plan default. Profile preferences populate the dropdown when the user opens the settings modal, that’s it. This kills the entire class of “I changed a profile preference six months ago and now my session is doing something weird” reports.
When || is not a bug
The pattern is bug-prone, not always wrong. || is fine when:
- The underlying type cannot meaningfully be the empty string (an integer ID where 0 is also not a valid value, or a UUID).
- You explicitly want both “missing” and “empty” to mean “use the default.”
The trap is when one of those conditions changes — a column type that used to be int NOT NULL becomes text with '' as a valid value, and the || chain three layers up silently breaks for that case. Code review can’t easily catch this because the chain still reads correctly; it just no longer behaves the way the author thought.
The shape of the lesson
There’s a class of bugs that comes from conflating “the absence of a value” with “a value that happens to be falsy.” Three of the most common:
- Empty string vs missing. This article.
- Zero vs missing.
count || DEFAULTwill useDEFAULTfor a real count of 0. falsevs missing.flag || truewill usetruefor an explicitfalse.
The fix in each case is the same: ask the question you actually meant. “Did this layer set a value?” is a membership question (in, !== undefined, ??), not a truthiness question (or, ||).
Lessons
- An empty string is a value.
""is not the same asNone/undefined, even though both are falsy. - Use
in/!== undefined/??for override chains.or/||only fall through correctly when the falsy values can’t be real choices. - Audit all layers when one is wrong. A bug pattern this intuitive tends to live in multiple places — for us, the same logic existed across server (
core/llm.py), web (ChatRoom.tsx), and Mini App (CharDetail.tsx). - Test the explicit-empty case. Most test suites cover “user didn’t pick anything” and “user picked option A.” Few cover “user picked the empty-default option.”
- Same bug in Python and JS. Resolver logic translates one-to-one, and so does the trap.
We’ve shipped roughly fifteen settings/preference resolvers since fixing this. All of them use membership checks. The class of bug is gone.
Related notes: OAuth state belongs on the client · Astro + Next.js + FastAPI deploy contracts · Sentry SDK noise filter.