Symptom

Source-driven nodes (Triggers, Calculations, TaskList, etc.) occasionally fired twice on a single root-cause change. The duplicates were not stable enough to catch in tests; they showed up as e.g. an SMTP email arriving twice from a single threshold cross, or a TaskList reset cascading where it should not have.

Underneath the surface, two unrelated complaints were both true:

Root cause

Phase 2 (unify-source-verb-wiring) shipped the source-owned-verb dispatch model and retired NodeState.RESET as a verb carrier — but it left the EXECUTE half of the dispatch riding the very anti-pattern it was supposed to retire:

  1. ServerNodeManager.update() called node.type.emit(node) on every persistence write. CRUD and wake were one function; a processor’s own persistence write re-entered itself.
  2. ServerNodeManager.doNodeVerbOnTarget() existed as a “self-execute” API whose only mechanism was stamping NodeState.EXECUTED onto a node and letting the observer wake the processor. Seven sites called it (cron tick, webhook ingress, click, task expiry, serial monitor, lifecycle startup, LogicGate hand-off).
  3. NodeProcessor.onSourceTrigger default impl was post(node.copy(state = NodeState.EXECUTED)) — source-triggered EXECUTE loops right back through the state-stamp / observer / post path.
  4. A receiver reached via both the originator’s type.emit (push) AND executeSources (pull) fired twice. EventMonitor’s PIN_CHANGED/SNAPSHOT_UPDATE handlers called executeSources after the underlying write that already went through update()’s type.emit.

The RESET-cascade was a separate but related design gap: each receiver had to remember to suppress executeSources(self) on RESET, and most didn’t. Forcing every downstream node type to be RESET-aware is the same silent-fall-back failure mode Phase 1 D1 rejected.

Fix

Make CRUD and invocation two separate concerns with a single, deliberate seam:

Regression-locked by InvocationSeamTest: a single source change invokes each subscriber exactly once; a Button click invokes its subscriber exactly once; a Cron tick fans out exactly once; meta.inputs plays no role in invocation dispatch (changing inputs without changing sources does not change who wakes). The worked-example end-to-end tests (TaskList → cross-server OutgoingWebHook → SMTP EXECUTE chain; Button-RESET-stops-at-TaskList chain) are pending and require a multi-host harness.

Prevention

Three rules, each enforceable by a structural test or compiler check:

  1. One seam. ServerNodeManager.invoke(target, by, verb) is the ONLY path that wakes a server processor. CRUD writes (update, create, delete, setStateToNone, alarm, updatePinState, updateMetaData, setErrorState) MUST NOT wake a processor. The test asserts that an update(node) to a node that is neither a source nor self-fired wakes no processor.

  2. No verb via state stamp. No code path writes state = NodeState.EXECUTED (or any state) with the intent of waking a processor. The non-exhaustive when (node.state) sweep over server processors confirms no EXECUTED arm drives forward logic — only UI/SSE signals.

  3. RESET is terminal at the receiver. A receiver’s process() cascades to executeSources(self) only when invoked with verb = EXECUTE. The InvocationSeamTest source-driven RESET test (worked-example, pending the multi-host harness) locks this in.

The lookalike trap: a CRUD-lifecycle post() arm (e.g. Pin’s USER_EDIT → reconfigure pi4j) looks like a state-driven wake but is doing legitimate side-effect work. Each such arm is flagged with a TODO(§2.2) comment naming the explicit hook it needs once type.emit is removed from update(). The rule: a post() body that calls a forward-logic helper is the anti-pattern; a post() body that responds to a CRUD state (USER_EDIT, CREATE_OR_OVERWRITE, DELETING) is a transitional bridge with a documented next step.

Follow-up (not in this PR)