Symptom

Not a field bug — the last structural anti-pattern from the Phase 1–4 dispatch rework. After Phase 4 a node that finished process() still drove its own fan-out by calling ServerNodeManager.executeSources(self), which did an O(N) nodePersistence.loadAll() scan on every value/state change to find the nodes listing it as a source and invoked each. The source was still responsible for “telling” its observers — a push wearing an observer costume — and the same coupling was duplicated in ~6 per-processor onSourceTrigger overrides and a global EventMonitor arm.

Root cause

The reverse edge (“who observes me?”) was recomputed by a full-table scan per change instead of being held as a live subscription. The genuine observer pattern (DefaultNodeObserver collecting a per-node Flow<Node>) existed but only client-side; the server abandoned it in Phase 4 because it self-waked (a write re-entered the writer’s own processor).

Fix

Bring the observer pattern to the server in the correct shape. ServerNodeManager now owns one hot per-node MutableSharedFlow<NodePublication> (flowFor(id)), published from the single CRUD write point (update) and from publish() for digital sources whose value is written outside update (DataPoint snapshot, pin change via EventMonitor). A new NodeObservationRegistry holds one collector job per observer that merges its sources’ flows and, on emission, wakes it through the unchanged seam invoke(observer, by = source.id(), verb = actionOf(source)). Lifecycle is driven off CRUD in CrudLifecycleTask (startup walk + wire on create/edit, unwire on delete) — no scan. executeSources, the per-processor onSourceTrigger overrides, and KrillApp.wakeFromSource are deleted; every fan-out point became a publish()/firing-update(). RESET is terminal by construction: invoke pins a thread-bound PropagationContext (via a ThreadContextElement) whose suppressPublish flag is set for a RESET verb, so the reset write’s publication is suppressed at the publish point rather than relying on each processor to remember. The same context carries a monotonic propagation epoch; each collector drops an emission whose (observerId, epoch) it already handled (bounded LRU), so a cyclic wiring fires each observer at most once per propagation and terminates.

Two design reconciliations worth recording:

Prevention

Cross-server (§8 — landed in the fast-follow on the same branch)

Cross-host source→observer never worked server-side before (the old executeSources scan only saw local nodes). Two halves were added:

Gotcha that bit: SOURCE_TRIGGERED was never emitted before, so SourceTriggerPayload was missing from the EventPayload polymorphic registration in Serializer.kt. Emitting it on /events would have crashed serialization at runtime (the swarm-wide “missing subclass = runtime crash” trap). Registered it + added SourceTriggeredEventSerializationTest as a guard.

Trade-offs / follow-ups