Every message has two possible authors
An inbound WhatsApp message on Invobot is handled by whichever system gets to it first. A rule engine tries to match it against known intents and templated replies - order status, hours, a pricing question - and answers automatically when it can. When it can't, the conversation queues for a human agent, who sees it appear in the agent console over a WebSocket feed and can claim it with one click.
Both paths write the same thing: an outbound message on the same conversation. Automation and a human being are racing to answer the same customer, and nothing about WhatsApp's delivery guarantees cares which one gets there second.
Where the race actually happened
The bot doesn't answer synchronously inside the request that receives the webhook. Matching an intent, filling a template, and sending back through the WhatsApp Business API all take long enough that the reply is queued as a background job. That gap - between a message arriving and the bot's reply actually going out - is exactly the window in which an agent can open the console and claim the conversation.
The earliest version of this system tracked that with independent flags on the conversation: bot_active, assigned_agent_id, awaiting_reply. Claiming a conversation set assigned_agent_id but didn't reliably clear bot_active, because the code path that claims a conversation and the code path that queues a bot reply were written at different times by different people, against the same table, with no single place that owned the combination. A background worker that only checked bot_active at the moment it queued the job had no way to know an agent claimed the conversation four seconds later, right before the job ran.
One status field, checked at the moment of writing
The fix collapses those independent flags into a single authoritative field on the conversation - status, one of a fixed set of values such as bot, queued, claimed, and resolved - plus the id of whoever holds it. Claiming a conversation is a single atomic transition away from bot or queued, and every other piece of code that might act on that conversation has to read the same field to know whether it's allowed to.
def claim_conversation(conversation_id: str, agent_id: str) -> bool:
# Atomic compare-and-set: only succeeds if nobody has already claimed it.
result = conversations.update_one(
{"_id": conversation_id, "status": {"$in": ["bot", "queued"]}},
{"$set": {"status": "claimed", "claimed_by": agent_id}},
)
return result.modified_count == 1The detail that matters is where the check happens. It isn't enough for the bot to check status when the reply job is queued - that check has already gone stale by the time the job actually runs, which is exactly the window the race lived in. The worker re-reads status immediately before it sends the outbound message, not before:
async def send_bot_reply(conversation_id: str, reply: str) -> None:
conversation = await conversations.find_one({"_id": conversation_id})
if conversation["status"] != "bot":
return # claimed, resolved, or otherwise no longer the bot's to answer
await whatsapp_client.send(conversation["customer_id"], reply)A single field with a fixed set of values is also what makes that guard trivial to write correctly. Four independent booleans have sixteen possible combinations, most of which mean nothing and some of which - bot active, agent assigned, not resolved - are exactly the state that produced the bug. An enum only has the states someone actually designed for.
The tradeoff: automation loses the ability to hedge
This costs something real. A single-owner status field cannot represent a bot drafting a suggested reply while an agent reviews it, or an agent skimming a conversation without formally claiming it - states that a more permissive boolean model could express by just setting an extra flag. Any of that has to live outside status, as metadata that doesn't change who is allowed to send the next message, or it doesn't get built at all until there's a real reason to extend the state machine on purpose.
It also puts a database round trip on the hot path of every automated reply, right before the message goes out, where a fire-and-forget design would have none. That cost is deliberate: a compare-and-set read is a few milliseconds against a WhatsApp API call that already takes longer, and it's the one place in the whole flow that decides whether the message should be sent at all. Skipping it to save the round trip is exactly how the original bug happened.
Why this is also an ops-visibility decision
The admin and support surfaces both need to answer the same question all day: what's outstanding, and who is on it. With independent flags that question required reconciling several fields and trusting that no code path had left them in a combination nobody designed for. With one status field it's a single query - conversations in queued are the backlog, conversations in claimed are being worked, and a count by status is a dashboard, not a migration.
That mattered more than the bug fix on its own. Invobot's admin panel and support portal are separate codebases from the agent console, built at different times, and both need to trust the same lifecycle without re-deriving it from raw flags. A shared enum any surface can read is what makes five separate deployables agree on the state of one conversation without talking to each other about it.
Where it stops applying
Not every entity in the system benefits from being collapsed to one status. A customer's open support tickets, their order history, their marked favourites - none of those are mutually exclusive states competing for the same write, so forcing them into a single field would just be modeling something that was never a race in the first place. The pattern is specifically for state where two systems can legitimately try to act on the same thing at the same time. A conversation with a bot and a human both able to answer it is that case. Most data isn't.