Symptom

A Trigger.LowThreshold / Trigger.HighThreshold (and Trigger.Color) that crossed and fired left no durable trace of having fired. Reading the node back afterward showed state NONE, timestamp 0 — identical to a trigger that had never been invoked at all. Only the server log proved the fire actually happened. A client (or an agent) with only the API to look at could not distinguish “fired, but nothing downstream is wired yet” from “mis-wired and never fires” — the single most common failure mode when building a new automation. It also meant a crossed threshold could never surface itself in ProjectScreen’s WARN/ERROR alert banner, since nothing ever moved the node out of NONE.

Root cause

ServerTriggerProcessor ends a successful crossing with nodeManager.succeeded(node). ServerNodeManager.succeeded() is documented (and, by design, used by ~20 other processors) as purely = publish(node) — it wakes source-observers but never persists anything. The trigger processor separately persisted the resolved threshold snapshot on every invocation (nodeManager.update(updatedNode, propagate = false)), but that write happens whether or not the threshold actually crosses, and never stamped anything to reflect the crossing itself. So a threshold with no observer wired was invoked, evaluated correctly, fired — and left the database byte-identical to its pre-invocation state.

Fix

ServerTriggerProcessor.kt: at each of the three fire points (Color bounding-box match, HighThreshold crossing, LowThreshold crossing), stamp the node with a real timestamp = Clock.System.now().toEpochMilliseconds() and persist it (nodeManager.update(fired, propagate = false)) immediately before calling nodeManager.succeeded(fired) on that same stamped node — mirroring the existing “persist first, then succeeded()” pattern already used by every other value-producing processor per ServerNodeManager’s “Completion contract” doc comment. succeeded() itself is untouched: it’s shared by every processor in the codebase, so the fix is scoped to the trigger fire path rather than changing what succeeded() means globally. Whether a crossed threshold should also enter NodeState.WARN (lighting up the existing ProjectScreen alert banner) was intentionally left out — that changes UI behavior for every existing trigger and is a separate product decision, not an observability bug fix.

Prevention