On a Cron→Calc→Counter swarm the Counter DataPoint’s displayed value
intermittently stuck (the node flashed/executed for 2–3 ticks but the value
held at its old number), then self-healed and jumped forward. The server
REST state (GET /node/{id}) was correct throughout — only the client view was
wrong. This is the same class as
2026-06-13-client-node-store-async-update-stale-overwrite, which #455 only
half-fixed.
ClientNodeManager replaced the whole node on every write. Two independent
writers — the SSE loop delivering SNAPSHOT_UPDATE (value) and the
observer-driven pulse delivering STATE_CHANGE (state) — each captured the node,
changed one field, and wrote it all back via update(node). A state write that
branched from a base captured before the value landed reverted the snapshot to
its stale value; when the race resolved the other way the value “jumped”. #455
removed the reordering from deferred launches but explicitly deferred the
robustness fix for genuinely concurrent writers (“a monotonic guard or
single-actor queue… Tracked for later”).
Field-scoped atomic mutation. New private mutateStored(id) { transform } runs
transform against the LATEST stored node via MutableStateFlow.update {} (CAS
loop), so a writer only ever changes its own concern. New applyState(id, state)
transitions only the lifecycle state; EventClient’s STATE_CHANGE arm now
calls it instead of a whole-node update(currentNode.copy(state)) from a
captured (stale) base. updateSnapshot, reset, startPairing, and alarm
were rewritten to mutate only their own field through mutateStored (with an
insert-on-absent fallback preserving prior behaviour). State-only and value-only
writers now commute. Regression: ClientNodeManagerConcurrentWriteTest
(red→green), including a test that characterises the old whole-node clobber and
proves applyState doesn’t reproduce it.
When two independent writers each own a different field of the same shared
StateFlow<T>, never read-modify-write the whole T from a captured base —
mutate the owned field atomically against the latest value (StateFlow.update {})
so the writers commute. A captured-node + copy + whole-node write is a
lost-update waiting to happen the moment a second concurrent writer touches a
different field. update(node) (whole-node replace) is now reserved for
authoritative full-node SSE payloads (CREATED, full _nodeUpdates); per-field
SSE events (STATE_CHANGE, SNAPSHOT_UPDATE, PIN_CHANGED) go through the
field-scoped mutators.