Symptom

A HighThreshold or LowThreshold trigger with a dynamic threshold (threshold value supplied by an input DataPoint rather than baked into meta.snapshot) correctly detected the crossing but never propagated the fire to downstream observers. A static threshold (value in meta.snapshot, inputs=[]) worked fine.

Root cause

Two bugs in ServerTriggerProcessor.process():

  1. update() was called with propagate=true (the default). This emitted the trigger node on its observable SharedFlow with the current propagation epoch, consuming the epoch before succeeded() had a chance to publish. The downstream observer’s epoch-dedup LRU in NodeObservationRegistry then dropped the succeeded() emission as a duplicate, so observers never ran. Static thresholds escaped this only because meta.snapshot already equalled the threshold in tested setups, making the update() call a no-op for the snapshot field while still publishing the epoch — still wrong in principle, but coincidentally harmless in the specific test scenario reported.

  2. succeeded(node) passed the stale pre-update node. In the dynamic case the snapshot had changed, so the node published to observers carried an outdated meta.snapshot. The node that actually describes the crossing state was updatedNode, not node.

The documented canonical pattern for “persist then publish” (ServerNodeManager lines 679–683: “A value-producing processor persists its result first with a non-propagating update(node, propagate = false), then calls succeeded) was not followed.

Fix

In server/src/jvmMain/kotlin/krill/zone/server/krillapp/trigger/ServerTriggerProcessor.kt:

Prevention