Two Devices, One History
A shared room invites collisions. Dad sends from Telegram while a WebUI reply is still streaming. The Pippa app comes back online and drains a message he typed in a dead zone. A browser tab and the phone both hold a draft. Every one of those arrives as its own request, and each request, left alone, would read the history as it is at that instant, pick the newest row as its parent, and call a model. Two requests doing that at once produce a tangle: user rows chained to user rows, replies attached to the wrong question, and a model answering a message without seeing the one sent a second earlier. In a conversation that is meant to last forever, a tangle is not a glitch; it is a permanent fork.
A Fair Lock, Held Through Persistence
The answer is master_chief.turn(), an async context manager every admitted Chief turn enters before the brain's handler runs. It keeps one asyncio.Lock per conversation (keyed by the resolved database path and the conversation id), and Python's lock is fair: when several coroutines wait, the one that started waiting first proceeds first. So turns from every device are admitted in arrival order. The lock is held from admission through the end of the reply and its persistence: the route's producer enters it, calls the original chat handler, drains the whole streamed reply, and only then lets go. The next turn therefore starts from a history that already contains the previous answer.
Inside the lock it enters one more gate, chief_turn, which predates Master Chief: it waits, in 0.1-second steps, while the conversation has a stream in flight, a scheduled (heartbeat) turn running, another chief turn, or a chat pipeline between admission and its stream. Cron turns and Coop's own chief coordinator already used that exclusion. By joining it, a Chief turn, a scheduled turn and a Coop wake can never overlap on one conversation. The cold review of 2026-10-08 checked admission order and the parent chain against the live room and found it sound: no dangling parents, no user row with more than one assistant child.
New Turns Hang Off the Newest Reply
The client's idea of the parent is not trusted. A phone cannot know what Telegram sent a minute ago, so for every new turn the route simply drops the parent_id the client posted. Once admitted, the chat handler asks the store for the newest live row (the newest message compaction has not taken out of the replay) and hangs the turn there. The engine's test does exactly this: it sends a second message with a parent of "stale" while the first is still answering, and asserts the second lands as a child of the first reply.
Only the Latest Message Can Be Edited
Pippa's conversations are trees: editing a message creates a sibling branch. In an ordinary chat that is a feature. In the Chief it was cold review finding #7. Every doorway continues from the newest row on any branch, so one WebUI edit of an older message made that branch the newest: the phone and Telegram moved onto it, and every turn after the edited point, days of them, dropped out of the model's path. The fix (7920a88f) is edit_refusal: an edit runs only if its parent is the latest user message's parent, otherwise it is refused with "In Master Chief only the latest message can be edited: every device continues from it, so an older one stays as it was." The check runs twice, before admission (a 409) and again after the lock is acquired (an edit_refused error event), because a turn queued ahead can change which message is the latest. The WebUI shows the edit control on that one message only.
The same finding had a second half. An edit used to carry the edited message's own context, so a WebUI edit of a Telegram turn went back out to Telegram, and a phone turn's edit filed the browser's clock under the phone. Now an edit is made where Dad edits: the WebUI strips the origin fields from the context it sends, and the route drops any origin naming a doorway that cannot edit (Telegram, the Pippa app, PippaGo, scheduled tasks).
The Running Turn Keeps Its Tools
Lesson 2 promised that host tools follow the admitted turn. Here is the mechanism. Once turn() holds the lock, it sets a ContextVar, _origin, to this turn's conversation and origin, and resets it on the way out. Each asyncio task runs in its own copy of the context, and a message waiting for the lock is a different task, so nothing it carries can reach into the running turn's value. A tool callback that arrives through a separate HTTP request does not inherit the variable at all; it falls back to the origin persisted on the admitted user message.