Symptom

ServerNodeManager.update() broadcast node changes to two independent streams — _nodeUpdates (SSE feed to UI clients) and nodeFlows (per-node observable flow for observer chains) — using two separate scope.launch blocks. Because both coroutines were enqueued independently, they could interleave with each other and with emissions from concurrent update() calls. With replay = 1 on nodeFlows, a stale emission winning the race became the persisted snapshot seen by any late-subscribing observer, potentially causing incorrect trigger evaluations or stale UI state.

Root cause

The original code called scope.launch { _nodeUpdates.emit(node) } and then separately called publish() which also called scope.launch { flowOf(node.id).emit(...) }. Two coroutines, no ordering guarantee between them, no barrier between concurrent writes.

Fix

Coalesce both emissions into a single scope.launch in update(), with _nodeUpdates.emit followed by flowOf.emit in sequence. The propagation decision (shouldPublish, epoch, hopTtl) is resolved on the calling thread before the launch (because propagationContextTL is a ThreadLocal). The remote peer announce (tryPostEvent SOURCE_TRIGGERED) remains outside the launch since it is fire-and-forget and does not need to be sequenced with local emissions. publish() is unchanged and still used by succeeded() and EventMonitor for their own (non-update) paths.

Prevention