Membership
Membership: SWIM-style cluster membership with a CRDT member view (P1 L3).
Each node is tracked as { status, incarnation }. A node refutes a false suspicion/death by incrementing its own incarnation and re-announcing Alive, so the merge rule is: higher incarnation wins; at the same incarnation the more-severe status wins (Dead > Suspect > Alive). That order makes merge a lexicographic max over (incarnation, severity) — associative, commutative, and idempotent — so gossiped member views converge regardless of order (the anti-entropy CRDT join for the small member set; no Merkle needed).
This is the pure membership state + merge + transitions; the SWIM probe loop (direct/indirect ping, suspicion timers) drives it from the runtime layer.
Origin fields (the cluster node service, specs/progress/2026-09-18-cluster-node-service.md): a member also carries its human name, its advertised listen address addr ("host:port", "" when unknown) and its creation (bumped each time the node process restarts). The merge order is lexicographic over (creation, incarnation, severity): a restarted node outranks every verdict about its previous life, whatever that verdict's incarnation, and name/addr travel with the winning observation. Still a max, so still a join. The two-argument constructors (alive(id, inc) ...) build creation 0 / name = id / addr "" -- the shape every pre-service caller and the legacy tag-3 gossip frame produce.
Types
Functions
An Alive observation of [node_id] at [incarnation], origin-less: creation 0, name = [node_id], addr "".
The members whose status is Alive, in unspecified (hash) order.
A Dead observation of [node_id] at [incarnation], origin-less: creation 0, name = [node_id], addr "".
The current winning observation for [node_id], or None if the view has never seen it.
A member observation with every field given: [name] is the human node name, [addr] its advertised "host:port" ("" when unknown), [creation] its restart counter.
Every member in the view, of every status (Dead and Suspect included), in unspecified (hash) order.
CRDT join of two member views: every member of [b] is observed into [a]. The result keeps, per node id, the winner under the observe order, so merging is associative and idempotent and, up to the name/addr of exact ties, commutative.
Fold one observation into the view, keeping the winner for its node id. The winner is the lexicographic max over (creation, incarnation, severity) with Dead > Suspect > Alive; on a full tie [obs] replaces the stored entry (so its name/addr win). An unknown node id is simply added.
Examples
let v = Membership.observe(Membership.empty(), Membership.alive("n1", 1))
let v = Membership.observe(v, Membership.suspect("n1", 1)) -- same inc: Suspect wins
let v = Membership.observe(v, Membership.alive("n1", 0)) -- older inc: ignored
-- Membership.get(v, "n1") is Some(suspect("n1", 1))The number of distinct node ids in the view (members of every status, Dead included).
A Suspect observation of [node_id] at [incarnation], origin-less: creation 0, name = [node_id], addr "".
A verdict about [node_id] at [incarnation], carrying the origin fields (name, addr, creation) this view already holds for it, so a Suspect/Dead about creation 5 is about creation 5 rather than a creation-0 observation the known member outranks. An unknown node gets the origin-less shape (creation 0, name = [node_id], addr ""). The view is not modified; pass the result to observe to record it.
[m] with a new [status] and [incarnation], keeping its node_id, name, addr and creation.