March Docs

ClusterNode

ClusterNode: one running cluster node -- it joins from seed addresses, runs SWIM continuously over one persistent authenticated connection pair per peer, and tells subscribers when a peer is suspected, dead or back.

Design: specs/progress/2026-09-18-cluster-node-service.md. The parts it assembles were already here (Membership/Swim/SwimDriver, NetKernel's handshake, PeerReader, NodeQueue); what was missing was a node that stays up and uses them together.

Shape: a PURE core (core_*, CnState -> ... -> (CnState, List(CnEffect)), unit-tested under the eval harness, which cannot run sockets) inside ONE actor (ClusterNodeActor) that owns the state and performs the effects, plus a few tasks around it:

acceptor   blocking tcp_accept loop; each connection is handshaked in its
           own task (deadline-bounded) and reported as `Accepted`
dialer     one task per attempt: connect_timeout + handshake, control
           then data, reported as `Dialed` / `DialFailed`
readers    one per connection (PeerReader.serve): control frames go to
           the actor as `CtlFrame`; end of stream is `LinkClosed(id, gen)`
ticker     `Tick` every tick_ms; the SWIM period, the redial backoff and
           the stale-dial expiry all run off it
(the readers, the ticker and the acceptor move to a new code epoch
after a hot deploy: see "moving to a new code epoch" below)
CtlWriter  one per peer: EVERY write to a control socket goes through it
           (two writers on one socket interleave frames)

Connections. One control + one data connection per peer, reused by everything. Two nodes dialling each other at once each end up with two pairs; both keep the pair dialled by the LOWER node_id (a symmetric rule, no message needed). Each pair has a generation; a close from a replaced generation is ignored. A closed connection is a HINT: the member goes Suspect and is redialled at once; a redial REFUSED (nothing listening) is definite and makes it Dead. When SWIM declares a node Dead the node shuts both its connections. Healing: SWIM never probes a Dead member, so the node redials every Dead member's advertised address on a backoff; when a redial lands, MEMBERS_SYNC in both directions lets each side refute what the other believed.

Names (the registry replica). The node holds a GlobalRegistry replica: a local register is pushed at once to every linked peer (REGISTRY_SYNC_RESP with the changed leaves), every new link exchanges the whole registry, and a 30 s anti-entropy round (REGISTRY_SYNC_REQ with the root hash) repairs what a push missed. A registration's clock is the name's current clock with our slot bumped, so a takeover after seeing a binding is causally newer and never falls to the tiebreak. lookup sees only VISIBLE bindings: one whose holder is Dead here, or whose holder has restarted since (a stale creation), is hidden -- not tombstoned; it reappears if the holder rejoins with the same creation. The owner tombstones its own stale-creation bindings when it learns of them. A global name is AP, not a lock: during a partition both sides can hold one; after the heal the CRDT picks one winner everywhere, and the loser's watchers get Lost.

Everything the node knows about members and names is also mirrored into Vaults (members, addr_of, lookup), so readers need no round trip to the actor.

The node is a capability: start(io, cfg) returns a Cap(ClusterNode.Live) whose dictionary (ClusterOps) every public operation calls through, and a test can attach(io, ops) its own (D35; see "the capability" below).

Types

typeCnConfigCnConfig = { name : String, listen_port : Int, advertise : String, seeds : List(String),#
typeCertFilesCertFiles = { cert : String, key : String, poll_ms : Int }#
typeNodeInfoNodeInfo = { node_id : String, name : String, addr : String, creation : Int, incarnation : Int }#
typeNodeEventNodeEvent = NodeUp(NodeInfo) | NodeSuspect(NodeInfo) | NodeDead(NodeInfo, String) | NodeRejoined(NodeInfo)#
typeSecurityEventSecurityEvent = HandshakeRejected(String, String) | FrameRejected(String, Int) | RawSendRefused(String, String)#
typeCnHalfCnHalf = { cfd : Int, dfd : Int, name : String, addr : String, creation : Int }#
typeCnStateCnState = {#
typeNameEventNameEvent = Bound(String, GlobalPid.Pid) | Unbound(String) | Lost(String, GlobalPid.Pid)#
typeRegisterErrorRegisterError = Taken(GlobalPid.Pid) | Stopped#
typeCnEffectCnEffect =#
typeCnHandleCnHandle = { pid : Int, name : String, node_id : String, advertise : String,#
typeNodeActorStateNodeActorState = { st : CnState, h : CnHandle, started : Bool }#
typeClusterOpsClusterOps = {#

Functions

fnaddr_ofaddr_of(c : Cap(ClusterNode.Live), node_id : String) : Option(String)#

The advertised address of [node_id], if this node has heard it.

fnall_membersall_members(c : Cap(ClusterNode.Live)) : List(Member)#

Every member this node knows of, in any status, itself included.

fnattachattach(io : Cap(IO), ops : ClusterOps) : Cap(ClusterNode.Live)#

Attach a cluster dictionary. With start, the only way to obtain a Cap(ClusterNode.Live); this one is the test seam: a test builds ops (usually { ClusterNode.ops_stub(id) with members: ..., subscribe: ... }) and hands the cap to code written against a real node.

fnauth_from_envauth_from_env(name : String, secret : String) : Result(ClusterAuth.Auth, String)#

How a node named [name] authenticates, from the environment, for code that opens connections of its own (SessionNode's direct runner, run_<Role>): certificate mode when MARCH_NODE_CERT is set (with MARCH_NODE_KEY and MARCH_CLUSTER_OPERATOR_PUBKEY, each a value or a file, and MARCH_CLUSTER_REVOCATIONS for the revoked list), exactly as config_from_env reads them; otherwise ClusterAuth.Secret([secret]).

fnauthorize_peerauthorize_peer(c : Cap(ClusterNode.Live), id : String, proto : String, role : String,#

May a node that presented [cert] play role [role] of protocol [proto] in [mode] ("offer" or "initiate")? Always Ok on a shared-secret node; on a certificate node, SessionAP.authorize against the peer's certificate (None: the peer presented none). What SessionNode's access points check when a session forms.

fnawait_membersawait_members(c : Cap(ClusterNode.Live), n : Int, timeout_ms : Int) : Bool#

Wait (polling every 50 ms) until at least [n] other nodes are Alive, or [timeout_ms] passes. True if they are.

fncert_update_messagecert_update_message(signed : NodeCert.Signed, from_id : String, to_id : String, binding : Bytes, counter : Int) : Bytes#

What a CERT_UPDATE's proof signs: the sender, the receiver, the link's handshake transcript [binding], the update's [counter] on that link, and the certificate's wire bytes.

fncertifiedcertified(c : Cap(ClusterNode.Live)) : Bool do own_cert(c) != None end#

Is this node in certificate mode? Every authorization check (the two-way role check at session formation, raw-send denial, registry visibility) applies only then; a shared-secret node checks nothing, as before step 11b.

fncheck_revocationcheck_revocation(cfg : CnConfig, r : NodeCert.SignedRevocation) : Result(NodeCert.Revocation, String)#
fnconfigconfig(name : String, listen_port : Int, advertise : String, seeds : List(String),#

A configuration with the defaults: SWIM period 1 s, ack timeout 500 ms, suspect timeout 3 s, 2 indirect helpers, tick 100 ms, redial backoff

  1. [advertise] ""
means `127.0.0.1:<listen_port>`. The SWIM timings trade detection speed
for false suspicions on a loaded host (a 300 ms ack timeout suspected
healthy peers on a busy CI runner); override `swim` / `period_ms` with a
record update to tune them. The environment can move the three SWIM
defaults without a rebuild: `MARCH_SWIM_PERIOD_MS`, `MARCH_SWIM_ACK_MS`
and `MARCH_SWIM_SUSPECT_MS` (each a positive integer; anything else keeps
the default). A record update still wins over them.

A name unregistered anywhere in the cluster leaves a tombstone in every
replica of the registry; it is dropped `tombstone_grace_ms` after it was
first seen (default 3 hours, `MARCH_REGISTRY_TOMBSTONE_GRACE_MS`). Keep it
well above the longest partition the cluster should heal from: a partition
longer than that can bring back a binding whose holder is gone, which the
node service then hides and its owner unregisters again.
fnconfig_from_envconfig_from_env() : Result(CnConfig, String)#

Configuration from the environment: MARCH_NODE_NAME, MARCH_NODE_PORT, MARCH_NODE_ADVERTISE (default 127.0.0.1:<port>), MARCH_CLUSTER_NODES (seeds, host:port,...), MARCH_CLUSTER_SECRET, MARCH_NODE_CREATION (default: the current time in ms, which grows across restarts on one host).

Certificate mode: with MARCH_NODE_CERT set, the node authenticates with a
certificate instead of the secret, and MARCH_NODE_KEY and
MARCH_CLUSTER_OPERATOR_PUBKEY must be set too (each may name a file
holding the value). MARCH_CLUSTER_SECRET is then ignored: both set means
certificate mode. Without MARCH_NODE_CERT the node runs in shared-secret
mode, as before (MARCH_NODE_KEY alone is an error, not a silent fallback).
MARCH_CLUSTER_REVOCATIONS (certificate mode; tokens or a file of them)
seeds the node's revocation list. When MARCH_NODE_CERT names a file, the
node re-reads it (and MARCH_NODE_KEY's file) every
MARCH_NODE_CERT_POLL_MS (default 10000) and replaces its certificate live
when they change (`replace_cert`; `cert_files`).
fncontrol_tagscontrol_tags() : List(Int) do [0, 1, 2, 3, 5, 6, 7, 8, 10, 11, 12, 13, 14, 15, 16] end#

The control-connection frame tags, all ClusterNode's own and exempt from raw-send denial: SWIM 0-3, REGISTRY_SYNC_REQ/RESP 5-6, MONITOR_REQ 7, MONITOR_FIRE 8, DELIVERY_FAILED 10, CREDIT 11, MONITOR_ACK 12, MEMBER_GOSSIP 13, MEMBERS_SYNC 14, REVOCATIONS 15, CERT_UPDATE 16. (9 is ACTOR_MSG, the only frame on a data connection, and the only one checked.)

fncore_check_certscore_check_certs(st : CnState, now_s : Int) : (CnState, List(CnEffect))#

Recheck every linked peer's certificate at [now_s] (unix seconds): drop the link of one that has expired or been revoked, and queue the member Dead with that reason (the tick that follows reports it).

fncore_dial_failedcore_dial_failed(st : CnState, now : Int, addr : String, refused : Bool) : CnState#

A dial to [addr] failed. [refused]: nothing listens there -- for a member we know at that address that is not already Dead, that is definite (PeerDown: Dead at the next tick, cause "connection refused"). Either way the address backs off, doubling to redial_max_ms.

fncore_dialedcore_dialed(st : CnState, dialed : String, id : String, name : String, peer_addr : String,#

A pair WE dialled to [dialed] ("host:port" as dialled -- a seed may be spelled differently from the peer's advertised [peer_addr]) is up: record which node that address reaches, clear its dial state, and link it.

fncore_dialed_boundcore_dialed_bound(st : CnState, dialed : String, id : String, name : String, peer_addr : String,#

core_dialed, for a link whose control-connection handshake transcript is [binding] (NetKernel.link_binding).

fncore_framecore_frame(st : CnState, from : String, tag : Int, frame : List(Int)) : (CnState, List(CnEffect))#

A control frame from [from] (tag [tag]). SWIM pings are answered at once; acks, gossip and member syncs are queued for the next tick; a ping-req is relayed (we ping the target, and pass its ack back to the requester). Frames of other families are ignored here (the registry and the data plane handle theirs).

fncore_linkedcore_linked(st : CnState, id : String, name : String, addr : String, creation : Int,#

A pair to [id] is up ([cfd]/[dfd], opened by [dialer], its control writer [cw]). Applies the duplicate rule, gives it a generation, records what the handshake said about the peer (name, advertised address, creation) as an Alive observation. A completed authenticated link directly proves a peer previously declared Dead or Suspect is alive, so its observation advances that peer's incarnation; otherwise a split brain can leave both sides retaining Dead records that neither can independently refute. It then sends our whole member view.

fncore_linked_boundcore_linked_bound(st : CnState, id : String, name : String, addr : String, creation : Int,#

core_linked, for a link whose control-connection handshake transcript is [binding] (NetKernel.link_binding).

fncore_local_downcore_local_down(st : CnState, pid : Int) : (CnState, List(CnEffect))#

A locally registered process died: unregister every name it held.

fncore_lookupcore_lookup(st : CnState, name : String) : Option(GlobalPid.Pid)#
fncore_membercore_member(st : CnState, id : String) : Option(Member) do Membership.get(st.driver.swim.members, id) end#
fncore_memberscore_members(st : CnState) : List(Member) do Membership.members(st.driver.swim.members) end#
fncore_newcore_new(cfg : CnConfig, seed : Int) : CnState#
fncore_peer_certcore_peer_cert(st : CnState, id : String, cert : Option(NodeCert.Cert)) : CnState#

The certificate peer [id] presented in its latest verified handshake (certificate mode); what peer_cert answers and step 11b checks.

fncore_peer_cert_updatecore_peer_cert_update(st : CnState, from : String, frame : List(Int), now_s : Int)#

A CERT_UPDATE from linked peer [from], at [now_s] (unix seconds): the certificate must verify under the operator key, be unexpired and not revoked, name the same node as the one [from] presented before, not be issued before it (NodeCert.supersedes), and come with a proof by its own key for this sender and receiver, THIS link's handshake transcript and a counter above the last update taken on this link. Ok with the new state (holding it as [from]'s certificate) and the certificate, or Err with why it was refused.

fncore_reg_syncedcore_reg_synced(st : CnState) : Bool do st.reg_synced end#

Has a peer's registry been merged here (or did this node start with no seeds to wait for)? For tests of the core.

fncore_registercore_register(st : CnState, name : String, pid : Int, watcher : Int) : (CnState, List(CnEffect))#

Register [name] -> local pid [pid] (its Down-watcher is [watcher]). Refused if another holder's binding is visible here -- the API checked the mirror first, so that is a race lost: the watcher is retired and the name's watchers get Lost(name, holder).

fncore_registrantcore_registrant(st : CnState, name : String) : Option(String)#

The recorded registrant of binding [name] (the certificate identity of the node that registered it, as this replica recorded it; "" when unknown), if the registry holds a present binding for it. Unlike lookup, not hidden.

fncore_rememberedcore_remembered(st : CnState) : Int do st.remembered end#

How many peer member views have shown this node as rejoining (self_stale); for tests of the core.

fncore_replace_certcore_replace_cert(st : CnState, signed : NodeCert.Signed, sk : Bytes) : (CnState, List(CnEffect))#

This node's certificate is replaced by [signed], whose key is [sk] (both already checked: replace_cert). Dials handshake with it from now on, and every linked peer is sent a CERT_UPDATE. A shared-secret node is unchanged.

fncore_revocationscore_revocations(st : CnState) : List(NodeCert.Revocation) do List.map(st.revs, fn r -> r.rev) end#
fncore_revokecore_revoke(st : CnState, r : NodeCert.SignedRevocation) : (CnState, List(CnEffect))#

Add a revocation (from the API or a peer): kept and passed on to every linked peer if it is the operator's and new here, ignored otherwise. The certificates it names are dropped at the next tick.

fncore_seed_selfcore_seed_self(st : CnState, addr : String) : CnState#

A dial of [addr] reached this node itself (a seed list naming us): never dial it again.

fncore_tickcore_tick(st0 : CnState, now : Int) : (CnState, List(CnEffect))#
fncore_unregistercore_unregister(st : CnState, name : String) : (CnState, List(CnEffect))#

Unregister [name] if this node holds it (compare-and-delete).

fncreationcreation(c : Cap(ClusterNode.Live)) : Int#

This node's creation (its incarnation number, part of every global pid it issues).

fncredentialscredentials(name : String, key_hex : String, cert_text : String, operator_hex : String)#

Certificate credentials for node [name] from their text forms: the node's ed25519 secret key (hex, 64 bytes), its certificate (NodeCert text form) and the operator public key (hex, 32 bytes). The certificate must verify under the operator key now, name [name], and name the secret key's public key -- a misconfigured node fails here, with the reason, rather than at every handshake.

fndecode_cert_updatedecode_cert_update(frame : List(Int)) : Result((NodeCert.Signed, Int, Bytes), String)#

A CERT_UPDATE frame's certificate, counter and proof.

fndecode_revocationsdecode_revocations(frame : List(Int)) : List(NodeCert.SignedRevocation)#
fndemonitor_remotedemonitor_remote(c : Cap(ClusterNode.Live), target : GlobalPid.Pid, r : Int) : ()#
fnencode_cert_updateencode_cert_update(signed : NodeCert.Signed, counter : Int, proof : Bytes) : List(Int)#
fnencode_revocationsencode_revocations(rs : List(NodeCert.SignedRevocation)) : List(Int)#
fnevent_textevent_text(ev : NodeEvent) : String#
fnframes_rejectedframes_rejected(c : Cap(ClusterNode.Live)) : Int#

Frames from this node's peers dropped because they failed their MAC (sealed connections: see specs/lang/clustering.md, "Per-frame MAC").

fnglobal_pidglobal_pid(c : Cap(ClusterNode.Live), pid : Int) : GlobalPid.Pid#

A global pid for the local actor [pid] on this node (to hand to a peer as a reply address).

fnhandshakehandshake(c : Cap(ClusterNode.Live), fd : Int, role : String, timeout_ms : Int) : Result(NetKernel.Authed, String)#

Run the cluster link's authenticated handshake (NetKernel.handshake_auth) on [fd], a connection that is not a cluster link, with this node's identity and credentials as of now: the peer must hold the cluster secret (shared-secret mode), or an operator-signed, unexpired, unrevoked certificate whose key it proves (certificate mode; the result's cert). [role] names the connection in the hello. Both ends call it. Used by the control API's candidate-to-candidate verbs (AUDIT_COPY, RELEASE_COPY); call NetKernel.forget(fd) when done, before the fd is closed.

fninfo_ofinfo_of(m : Member) : NodeInfo#
fnis_session_tagis_session_tag(type_tag : String) : Bool do NodeSend.is_session_tag(type_tag) end#

Is [type_tag] session traffic (session_tags(), ignoring a \

fnlookuplookup(c : Cap(ClusterNode.Live), name : String) : Option(GlobalPid.Pid)#

The process bound to [name] cluster-wide, as this node sees it (a Vault read). In certificate mode a binding is hidden unless a raw send to its holder would be allowed (both certificates carry raw_send; this node's own bindings and the coordination namespaces reference_namespaces() always show): a name you cannot raw-send to is not a reference you should hold.

fnmembersmembers(c : Cap(ClusterNode.Live)) : List(NodeInfo)#

Every other node this node knows as Alive.

fnmonitor_remotemonitor_remote(c : Cap(ClusterNode.Live), target : GlobalPid.Pid, on_down : DistLink.DownReason -> Unit) : Result(Int, String)#

Monitor the remote process [target]: [on_down] gets its DownReason once, when it exits (Normal/Killed/Crash) or when its node is declared dead (NodeDown). The fire is at-least-once on the wire and delivered once here. Err when this node has no link to [target]'s node.

fnname_event_textname_event_text(ev : NameEvent) : String#
fnname_ofname_of(c : Cap(ClusterNode.Live), node_id : String) : Option(String)#

The name of [node_id], if known.

fnnamesnames(c : Cap(ClusterNode.Live)) : List(String)#

Every name with a visible binding here.

fnnext_idnext_id(c : Cap(ClusterNode.Live), key : String) : Int#

A per-node counter: the next value (from 1) under [key]. Code that needs ids unique on this node (SessionAP's session numbers) draws them here.

fnnode_idnode_id(c : Cap(ClusterNode.Live)) : String#

This node's id.

fnnode_id_ofnode_id_of(name : String) : String do NodeIdentity.name_id(name) end#
fnoff_peer_closedoff_peer_closed(c : Cap(ClusterNode.Live), id : Int) : ()#
fnoff_security_eventoff_security_event(c : Cap(ClusterNode.Live), id : Int) : ()#
fnoff_session_failedoff_session_failed(c : Cap(ClusterNode.Live), key : Int) : ()#
fnon_delivery_failedon_delivery_failed(c : Cap(ClusterNode.Live), f : Int -> String -> Unit) : ()#

Call f for every DELIVERY_FAILED a peer sends back, except for a session's frames (a negative seq, session_seq): those go to the session's own handler (on_session_failed).

fnon_peer_closedon_peer_closed(c : Cap(ClusterNode.Live), f : String -> String -> Unit) : Int#

Call f whenever a data connection ends, from that connection's reader, after its last delivery. [why] is "node <name> dead: <cause>" when SWIM declared the peer dead, "replaced" when a duplicate pair was closed, else "connection lost". Returns an id for off_peer_closed.

fnon_security_eventon_security_event(c : Cap(ClusterNode.Live), f : SecurityEvent -> Unit) : Int#

Call [f] on every security event: a handshake refused (the peer's certificate did not verify, it runs in the other mode, the wrong secret, ...). Returns an id for off_security_event.

fnon_session_failedon_session_failed(c : Cap(ClusterNode.Live), key : Int, f : String -> String -> Unit) : ()#

Call f for every DELIVERY_FAILED a peer sends back for a frame sent with a session_seq under [key] (a frame the peer could not route: its endpoint is gone, a stale creation, or refused). Replaces any handler under [key]; off_session_failed removes it.

fnops_stubops_stub(node_id : String) : ClusterOps#

A dictionary for node [node_id] in which every operation panics, naming itself. Override the fields a test uses with record update.

fnown_certown_cert(c : Cap(ClusterNode.Live)) : Option(NodeCert.Cert)#

This node's own certificate (certificate mode), or None (shared-secret mode). ops_stub's answer is None, a shared-secret node: every step-11b check is off unless a test overrides it.

fnpeer_certpeer_cert(c : Cap(ClusterNode.Live), node_id : String) : Option(NodeCert.Cert)#

The certificate peer [node_id] presented in its latest verified handshake (certificate mode), or None (shared-secret mode, or no such peer). The step-11b checks (session roles, raw sends, registry visibility) read it.

fnqueue_forqueue_for(c : Cap(ClusterNode.Live), node_id : String) : Option((Pid(NodeQueue.WriterState), Int))#

The outbound queue of the data connection to [node_id], for Node.enqueue(q, to, msg, policy) (the typed send) or NodeQueue.cast. Every write to a data socket goes through this one writer, so this is the only way to send on it: Node.send(peer, ...) writes the socket directly and would interleave with the queue.

This is a RAW send path: in certificate mode it is None (counted in
`raw_refused`, reported as `RawSendRefused`) unless this node's and the
peer's certificates both carry `raw_send`. The peer refuses a raw frame
it receives from a node without raw_send in any case.
fnraw_allowedraw_allowed(own : Option(NodeCert.Cert), peer : Option(NodeCert.Cert)) : Bool#

May a raw ACTOR_MSG cross a link between a node holding [own] and a peer that presented [peer]? Always in shared-secret mode ([own] None); in certificate mode only when both certificates carry raw_send.

fnraw_refusedraw_refused(c : Cap(ClusterNode.Live)) : Int#

Raw sends this node refused (certificate mode, step 11b): outbound ones (send_msg's Err(NotAuthorized), queue_for's None) and inbound raw frames dropped from a peer. Each is also a RawSendRefused security event.

fnreference_exemptreference_exempt(name : String) : Bool#

Is [name] in a namespace lookup never hides (reference_namespaces())?

fnreference_namespacesreference_namespaces() : List(String) do ["ap:", "session:", "topo:"] end#

The registry name prefixes exempt from certificate-mode hiding: SessionNode's and Topology's coordination names.

fnregisterregister(c : Cap(ClusterNode.Live), name : String, pid) : Result((), RegisterError)#

Register [name] -> [pid] (a process on this node) cluster-wide. Refused with Taken(holder) if another holder's binding is visible here, or another local pid's registration of [name] is still pending (so two concurrent local registrations of one name cannot both succeed). Ok means the binding is made here and pushed to every peer; it is not a lock -- a node on the far side of a partition can hold the same name, and after the heal one binding wins everywhere (watch the name to hear Lost). The check is against this node's view; if another binding reaches the node first, the registration is refused there and the name's watchers get Lost(name, holder). A process already dead by then is not registered. The process is monitored: its death unregisters the name.

fnregistrantregistrant(c : Cap(ClusterNode.Live), name : String) : Option(String)#

Who registered [name]: the certificate identity (URI) of the registering node as this node recorded it, "" when unknown (shared-secret mode, or a relayed binding whose holder this node has not verified). None when no present binding exists. Not hidden the way lookup is.

fnregistry_syncedregistry_synced(c : Cap(ClusterNode.Live)) : Bool#

Has this node merged a peer's registry replica yet (the sync a peer sends when a link comes up)? Always true for a node started with no seeds: it has no one to wait for. Until then this node's view of the names (and of Topology's eligibility markers) is its own alone.

fnrejoined_atrejoined_at(c : Cap(ClusterNode.Live)) : Option(Int)#

When (unix ms) a peer last showed this node as rejoining: its member view held this node at an earlier creation (a restart) or Dead (a restart that kept its creation, a heal). That peer reports the node's return as NodeRejoined and lets it settle before counting it; None if no peer has.

fnrelease_claimrelease_claim(claims : Vault(Int), name : String, p : Int) : ()#

Drop [name]'s pending claim (see reserve) if pid [p] holds it.

fnreplace_certreplace_cert(c : Cap(ClusterNode.Live), cert_text : String, key_hex : String) : Result(NodeCert.Cert, String)#

Replace this node's certificate while it runs (certificate mode; distributed-deploys step 12b). [cert_text] is the new certificate in its text form (forge cluster cert's .cert file); [key_hex] is its node key (hex, as in MARCH_NODE_KEY), or "" to keep the current key (a renewal made with forge cluster cert --node-key). It must verify under the operator key now, name this node and that key, and not be revoked; otherwise Err, and the certificate in use is kept. The certificate and key already in use are Ok and change nothing (no update is sent, no event reported).

New handshakes present it at once. Links already up are not redialled:
each linked peer is sent the new certificate over the link, with a proof
that this node holds its key, checks it as a handshake would, and holds it
for that link from then on -- so the link, and every session on it, stays
up past the old certificate's expiry. A peer too old to know the update
drops the link when the old certificate expires, and the redial then
handshakes under the new one. Reported to `on_security_event` as
`CertReplaced` (or `CertRefused`). `config_from_env` nodes also do this
on their own when the MARCH_NODE_CERT file changes (see `CnConfig`).
fnreservereserve(names : Vault(GlobalPid.Pid), claims : Vault(Int), node_id : String, creation : Int,#

The synchronous half of register: decide, in the caller, whether local pid [p] may take [name] on node [node_id] (at [creation]). [names] is the node's mirrored view; [claims] holds the registrations sent to the node actor but not yet processed there (name -> pid). The claim is taken first (Vault.put_new, atomic), then the view is checked, and the node actor mirrors a binding before releasing its claim (release_claim), so two concurrent registrations of one name by different pids cannot both get Ok. The same pid registering again is allowed. On Ok the caller owns the claim and must send the Register that releases it.

fnrevocationsrevocations(c : Cap(ClusterNode.Live)) : List(NodeCert.Revocation)#

Every revocation this node knows (from MARCH_CLUSTER_REVOCATIONS, revoke, or its peers).

fnrevocations_from_textrevocations_from_text(text : String, operator_pubkey : Bytes) : Result(List(NodeCert.SignedRevocation), String)#

Revocation tokens (forge cluster revoke's output) separated by whitespace or commas, each verified against the operator key.

fnrevokerevoke(c : Cap(ClusterNode.Live), token : String) : Result((), String)#

Revoke a certificate cluster-wide: [token] is forge cluster revoke's output, signed by the operator key. The node drops any link whose certificate it names (the peer is reported NodeDead(_, "certificate revoked")), refuses that certificate's handshakes, and passes the revocation on to every peer. Err for a token that does not parse or is not the operator's, or on a shared-secret node.

fnrouteroute(c : Cap(ClusterNode.Live), pid : Int, deliver : NodeSend.Delivery -> Result((), String)) : String#

Deliver every ACTOR_MSG addressed to local pid [pid] to [deliver], called in the data reader's task (it should only send). Its Err(reason) goes back to the sender as DELIVERY_FAILED. Returns the key for unroute.

A route is a raw receiver: in certificate mode a delivery from a peer is
refused before it reaches [deliver] (DELIVERY_FAILED to the sender, counted
in `raw_refused`, reported as `RawSendRefused`) unless this node's and the
sender's certificates both carry `raw_send`.
fnroute_sessionroute_session(c : Cap(ClusterNode.Live), pid : Int, deliver : NodeSend.Delivery -> Result((), String)) : String#

As route, for a SESSION endpoint (SessionNode's parties, access points and initiator inboxes): it takes session traffic (session_tags()) from any certified peer, raw_send or not, and refuses anything else from a peer where raw sends are refused. The session layer checks the rest: frames carry the session id, and access points check certificates' roles.

fnroute_typeroute_type(c : Cap(ClusterNode.Live), type_tag : String, deliver : NodeSend.Delivery -> Result((), String)) : String#

As route, for every message of [type_tag] with no pid route (typed receivers).

fnsecurity_textsecurity_text(ev : SecurityEvent) : String#
fnsend_msgsend_msg(c : Cap(ClusterNode.Live), to : GlobalPid.Pid, type_tag : String, payload : List(Int),#

Send [payload] (an encoded message of type [type_tag]) to the remote process [to] through its node's data queue. Ok(seq) means admitted, not delivered: a delivery failure comes back as DELIVERY_FAILED (see on_delivery_failed). Err(NoConnection) when this node has no link to [to]'s node.

In certificate mode a send whose [type_tag] is not session traffic
(`session_tags()`) is a raw send: `Err(NotAuthorized)` (counted in
`raw_refused`, reported as `RawSendRefused`) unless this node's and the
peer's certificates both carry `raw_send`. Sends to this node itself are
never refused.
fnsession_queue_forsession_queue_for(c : Cap(ClusterNode.Live), node_id : String) : Option((Pid(NodeQueue.WriterState), Int))#

queue_for for SESSION traffic (SessionNode's cluster links): never refused for want of raw_send, since what it carries is checked at the receiver (session frames reach only session routes). Not for raw sends: a raw frame sent through it is refused by a receiver that would refuse it anyway.

fnsession_seqsession_seq(key : Int, n : Int) : Int#

The wire seq of the [n]th frame a session sends under [key] (> 0, never reused in this process: SessionNode draws it from a process-wide counter, so a late refusal of an ended session's frame finds no handler rather than another session's). Negative, so it never collides with a user's own seqs: a DELIVERY_FAILED for it goes to the handler on_session_failed installed under [key], not to on_delivery_failed's.

fnsession_seq_keysession_seq_key(seq : Int) : Int#

The key a session_seq was made under.

fnsession_tagssession_tags() : List(String) do NodeSend.session_tags() end#

The ACTOR_MSG type tags that are session traffic, exempt from raw-send denial when they reach a session route: SessionAP's invitation, withdrawal and answer, and SessionNode's frames (cluster sessions append "#<sid>"). The list is NodeSend.session_tags(), shared with the direct connections.

fnsign_cert_updatesign_cert_update(signed : NodeCert.Signed, sk : Bytes, from_id : String, to_id : String,#

The CERT_UPDATE frame node [from_id] sends [to_id] on the link whose handshake transcript is [binding], as its [counter]th update there: [signed], with a proof by its key [sk].

fnstale_bindingsstale_bindings(c : Cap(ClusterNode.Live)) : Int#

How many present bindings of this node's from an earlier creation the replica holds (0 once retired).

fnstartstart(io : Cap(IO), cfg : CnConfig) : Result(Cap(ClusterNode.Live), String)#

Start a node: listen on cfg.listen_port, begin SWIM, dial the seeds. Returns at once -- a node that reaches no seed runs as a cluster of one and keeps redialling them (use subscribe or await_members to wait for peers). Err only if the listen socket cannot be opened. The node is a Cap(ClusterNode.Live) minted from [io]; pass it on to everything that uses the cluster.

fnstopstop(c : Cap(ClusterNode.Live)) : ()#

Stop the node: close every link (the loopback too) and the listener, stop the ticker.

fnsubscribesubscribe(c : Cap(ClusterNode.Live), on_event : NodeEvent -> Unit) : Int#

Call [on_event] (in the node actor's turn: it must only send) for every membership change. A subscriber added late gets NodeUp at once for every member currently Alive. Returns an id for unsubscribe.

fntag_cert_updatetag_cert_update() : Int do 16 end#
fntag_revocationstag_revocations() : Int do 15 end#
fnunregisterunregister(c : Cap(ClusterNode.Live), name : String) : ()#

Unregister [name], if this node holds it. A name another node holds is left alone.

fnunrouteunroute(c : Cap(ClusterNode.Live), key : String) : ()#
fnunsubscribeunsubscribe(c : Cap(ClusterNode.Live), id : Int) : ()#
fnunwatchunwatch(c : Cap(ClusterNode.Live), id : Int) : ()#
fnwatchwatch(c : Cap(ClusterNode.Live), name : String, on_event : NameEvent -> Unit) : Int#

Call [on_event] (in the node actor's turn: it must only send) on every change to [name]'s visible binding: Bound(name, pid), Unbound(name), or Lost(name, winner) when a binding made on THIS node is superseded by another holder's (after a partition heals, say). A watcher added while the name is bound gets Bound at once. Returns an id for unwatch.