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.
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.
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.
succeeded() → publish()), don’t
assume that also means “this is now visible in a read.” Check whether the node’s persisted
state actually changes on the fire path, not just whether something reacts to it — a node
with zero wired observers is a valid, common configuration and must still be self-describing
on a plain read.succeeded()’s doc comment already states the pattern precisely: “a value-producing
processor persists its result first with update(node, propagate = false), then calls
succeeded().” Any new fire path added to an existing processor should be checked against
that contract, not assumed to inherit it just because succeeded() is called.