macula_client (macula v13.2.1)

View Source

macula_client — the canonical pool client.

Holds N peering links to N stations and routes ops with replication, subscription replay, and inbound-event dedup. Apps don't manage individual macula_station_link workers; they call macula_client (or the macula facade, which re-exports the public surface).

Per PLAN_V2_PARITY Q2 §1: pool is the canonical client handle. A single-station link is an internal worker only.

Lifecycle

  {ok, Pool} = macula_client:connect(Seeds, Opts).
  ok          = macula_client:publish(Pool, Realm, Topic, Payload, #{}).
  {ok, Sub}   = macula_client:subscribe(Pool, Realm, Topic, self(), #{}).
  receive {macula_event, Sub, Topic, Payload, Meta} -> ... end.
  ok          = macula_client:unsubscribe(Pool, Sub).
  ok          = macula_client:close(Pool).
  

Replication

publish/5 fans the PUBLISH frame to replication_factor (default 2 with >=2 connected links, since 10.19.0 -- see ?DEFAULT_REPLICATION's own doc for exactly what this does and does not protect against) currently-connected links. **Partial success counts as success** per PLAN_V2_PARITY §5.1.1: the call returns ok as soon as one link accepts the frame; the others are best-effort. When zero links are spawned the call returns {error, {transient, no_healthy_station}}.

subscribe/5 applies to every spawned link. The pool delivers a deduped event stream to the consumer regardless of which link relayed any given EVENT.

Dedup

A link verifies each publication before it hands the event to the pool, and the pool delivers each publication at most once. It keys an ETS table it owns on the event's publication_hash, the SHA-384 of the publication's tbs, and keeps each entry until the publication's expires_at, after which every verifier refuses it. The table is swept every dedup_sweep_ms (default 30s). The pool checks an event only while a subscription matches it, so a copy that arrives while nothing is subscribed never hides the publication from a later subscriber, and it drops an event whose expires_at has passed when the check runs.

Replay

When a link's process dies the pool monitor fires; the pool schedules a respawn after ?LINK_RESPAWN_DELAY_MS (1s). On respawn, the pool re-issues every currently-tracked (Realm, Topic) subscription against the new link via the internal macula_client_replay helper, and starts the link with the options it was started with before (#link_state.extra_opts), which is what keeps a direct dial's expected_node_id across a bounce.

Summary

Types

A seed map may name the node_id its station must prove (a pinned seed), which stands over the pool's expected_node_id option: see connect/2.

Why a seed's link last went down, and when (at_ms, system time). reason is the reason's name only, never its terms. A peer_identity_mismatch also names the node id the seed expected and the one the station presented, in lowercase hex: both are public, and an operator needs both to tell a stale pin from a station whose identity moved.

How a registration renews its chain (D32): apply(M, F, [Pool | A]) answers {ok, Spec} or {error, Reason}.

Per-link view returned by links/1. One entry per configured seed that currently has a spawned link worker. node_id is the peer station's pubkey (undefined until CONNECT/HELLO completes); host is the dial host parsed from the seed. last_disconnect is why this seed's link last went down, kept across the respawn that replaced it, or undefined if it never has: see last_disconnect().

Functions

Register a procedure handler on every healthy link. Stored in pool state so a respawned link registers it again. A caller reaches this provider only through a procedure_advertisement record that names it; registering the handler publishes none. Returns ok when at least one link accepted the registration. A handler that answers {error, Text} with a binary or a printable charlist sends that text to its caller, up to 256 bytes of it; any other error reason reaches the caller as its name only.

Advertise with an auth policy -- see auth_policy() above for the full set (open | {ucan_required, IssuerNodeId} | {realm_member_required, RealmKeyId, RequiredCan}).

As advertise/5, with the provider advertisement (the resolved D25 authorization included) fanned out to every link as an ADVERTISE frame. Ad is an advertisement spec each link signs per send, naming its own station (what the facade passes), or a pre-signed advertisement's wire form every link sends as it is. The facade resolves the authorization before calling here; undefined registers the handler locally and sends no frame.

As advertise/6, registered on the links to Stations only, each named by the node_id its link pins, or on every link for all. A station the pool holds no link to is refused as {error, {station_not_linked, StationNodeId}}, and nothing is registered or kept. A link respawned later registers the procedure again only when its station is one of Stations.

As advertise/7, renewing the chain the spec carries (D32). A procedure_delegation lives 30 minutes, so at a third of the spec's remaining life the pool calls apply(M, F, [Pool | A]) in a worker of its own, which answers {ok, Spec} or {error, Reason}, and registers the fresh spec on the same stations. A failure or a crash is retried on a backoff that never passes the spec's not_after; past it the pool logs at error level, naming the procedure and the last reason, and asks again every renew_recheck_ms, so a re-grant revives the provider without a restart. A spec without a chain (own namespace, a pre-signed advertisement, undefined) is not renewed, and neither is one given undefined. An unadvertise, or another advertise of the procedure, supersedes a renewal in flight: its answer is dropped.

Advertise a streaming procedure handler on every healthy link. Stored in pool state so links respawned later replay the advertisement. Returns ok when at least one link accepted the registration. Same as advertise_stream/6 with policy open.

Advertise a streaming procedure with an auth policy -- the same auth_policy() set advertise/5 takes. The policy is stored with the procedure, so a link respawned later re-advertises it still gated.

As advertise_stream/6, with the provider advertisement fanned out to every link as an ADVERTISE frame: a spec each link signs naming its own station, or a pre-signed wire form (see advertise/6). undefined registers the handler locally and sends no frame.

As advertise_stream/7, registered on the links to Stations only, as advertise/7 registers a procedure.

Issue a CALL for a procedure the pool's linked stations serve themselves, such as _dht.*: first success across the pool's healthy links, each CALL targeting the station its link is connected to. It moves on to the next link only when the CALL never went out on the one before (macula_station_link:not_sent/1). Returns {error, no_healthy_station} when no link has completed its CONNECT/HELLO handshake. A procedure a provider serves is called through macula:call/5, which resolves the provider.

Issue a CALL to Target, a provider's node_id, at ONE specific station, dialing it directly even if it is not in the pool's seed set. Station is a seed URL (e.g. <<"quic://[::1]:4433">>). The pool ensures a link to it (reusing an existing one, or dialing and monitoring a new one exactly like a seed), waits for the handshake within the deadline, and calls through that link; the station delivers the CALL to the provider Target names. This is the direct-dial data path: resolve a provider's serving_station to its endpoint, then reach it in one hop here, with no mesh relay.

As call_station/11. Reporttrue asks for the call's seal report: a result then comes back as {ok, Result, Report} (see macula_station_link:call/9); an error is returned as it is.

Open a streaming RPC to Target, a provider's node_id, by DIALING a specific station directly (direct-dial). The streaming analogue of call_station/11: ensure (reuse or dial) a link to Station, await the handshake, then open the stream there, naming Target. Opts may set dial_timeout_ms (default 10_000) for the dial and handshake, plus any stream option (e.g. mode). Opts also names the station this dial must prove, expected_node_id, as call_station/11 does. It is kept apart as the dial's own option, so it reaches ensure_link/3 and not the stream open. Seal is how the STREAM_OPEN and its stream go, clear or {sealed_to, KemKey}, decided by the caller from signed state (macula:call_stream_station/7 decides it), never defaulted.

OTP child spec — drop the pool into a caller's supervision tree. Id is the supervisor child id. A supervisor keeps the spec for its child's life, so the spec names how to load the node identity key and never holds the key: give node_identity as a loader {Module, Function, Args} that returns {ok, Key}. Args say where the key is, such as a file name, and never hold the key, because a supervisor that fails to start the pool logs the spec, Args included. A key, or a function that could hold one, given here raises {node_identity, loader_required}.

Stop the pool. Every subscriber receives a final {macula_event_gone, SubRef, pool_closed} message; every link terminates with the pool.

Spawn a pool with one link per seed. Returns immediately; link handshakes complete asynchronously. Publish/subscribe block until at least one link is connected (or fail with {error, {transient, no_healthy_station}} on the publish path). A node with no crypto profile, or a node_identity that is not an identity key in the node's profile, starts no pool: the refusal is returned and no link is dialed. Nor does a seed that names no node_id it expects, in the seed or in the expected_node_id option: the start returns {error, {seeds, expected_node_id_required}}. A verify key, in the options or on a seed, returns {error, {seeds, {verify, one_verification_mode}}}: there is one verification mode and it is not the caller's to pick.

A pool link to Station: a live one the pool holds, else one it dials, pinned to the station node_id in the seed or in LinkOpts (expected_node_id), and connected within TimeoutMs. The pool owns and monitors it, respawns it, and ends it when the pool ends. Answers {error, not_connected} when no handshake completes in time. Called through macula:ensure_station_link/4.

Per-link snapshot of the pool — one link_info() per configured seed that currently has a spawned link worker. Unlike status/1 (which only aggregates counts), this exposes each link's node_id (peer station pubkey), dial host, pid, and connected flag, so a caller can resolve a specific station (by pubkey or hostname) to its link and address it directly.

Publish a frame to replication_factor currently-connected links. Partial success = success. Realm is per-call (32 bytes) and identical across every replicated link — a wrong Realm here blackholes the publish on every selected station alike, regardless of replication_factor; see ?DEFAULT_REPLICATION's own doc for what raising the factor does and does not protect against.

The realm key the pool pinned for RealmId when it started, from its realm_trust option, or none. Direct dial checks an org namespaced advertisement's authorization against this key alone.

Remember Candidate as the station that answered Procedure in RealmId, usable for TtlMs more milliseconds.

The station that last answered Procedure in RealmId from this pool, as a direct-dial candidate plus the seed the pool's own live link to that station is keyed by, or none.

Sign a domain record (tags 0x20 to 0xFF) as this node, with the pool's node identity key, in the pool's own process, and return the signed record, stored under this node's key id with its subject when it has one. Build it with macula_record:envelope/3. The pool stamps it with a new version and created_at, keeping the lifetime it was built with. Only the record's type, created_at, expires_at, payload and subject reach the pool, and each of these is refused before the call: a type outside 0x20 to 0xFF, {error, not_a_domain_type}; a subject that is not a non-empty binary, {error, invalid_subject}; a lifetime past the domain maximum of 7 days, or running backwards, {error, lifetime_too_long} or {error, lifetime_reversed}, never shortened to fit; a payload and subject over 256 KiB together, {error, record_too_large}; and a term that is no domain record, {error, malformed_record}. A signed record that would pass 256 KiB is {error, record_too_large} from the pool. Withdraw a domain record with withdraw_node_record/3.

Sign a record this node signs about itself with the pool's node identity key, in the pool's own process, and return the signed record: the node record, a procedure advertisement or a content announcement that names this node. The pool stamps it with a new version and created_at, keeping the lifetime it was built with. The key never leaves the pool, so a caller never holds it. Only the record's type, created_at, expires_at and payload reach the pool. A record of another type, a tombstone included, is {error, not_a_node_signed_type}; one that names another node {error, key_id_mismatch}; one whose lifetime passes its type's maximum or runs backwards {error, lifetime_too_long} or {error, lifetime_reversed}; a payload over 256 KiB, refused before the call, or a signed record that would pass 256 KiB {error, record_too_large}; a record with a subject, which no type a node signs about itself carries, refused before the call, and anything else the pool cannot sign {error, malformed_record}.

As sign_node_record/2, bounded by Optsnot_after (a Unix millisecond). The pool judges the bound on its own clock: one already passed is {error, not_after_passed}; one before the record's lifetime runs out ends the record at the bound; one after keeps the built lifetime. The refusals of sign_node_record/2 stand under a bound. Opts without not_after signs as sign_node_record/2 does, and a not_after that is not an integer raises function_clause in the caller.

Aggregate health snapshot of the pool. A single round-trip to the pool's gen_server, answered from the pool's own state: no link is asked, so a busy link delays nothing. Suitable for /health or /status endpoints.

Subscribe Subscriber to (Realm, Topic). The pool subscribes every currently-spawned link and dedupes inbound events before fan-out. Returns {ok, SubRef}, or the topic's refusal when a SUBSCRIBE cannot carry it (over 512 bytes, or not UTF-8); Subscriber receives {macula_event, SubRef, Topic, Payload, Meta} for each delivered event and {macula_event_gone, SubRef, Reason} once when the pool closes or the subscriber pid dies.

Drop a previously-advertised procedure on every healthy link and remove it from the pool's replay state. Idempotent.

Drop a streaming procedure on every healthy link and remove it from the pool's replay state. Idempotent.

Drop a subscription. Idempotent — unknown SubRef is a no-op. The wire-level link subscription persists for the pool's lifetime (one wire sub per (Realm, Topic) multiplexed across local consumers); Phase 4 will tighten this.

Sign a tombstone that withdraws a record this node signed, with the pool's node identity key, in the pool's own process. The pool first verifies the record, as its wire form or its signed map, under its profile, and withdraws it only when it is of a type a node signs about itself or a domain type, and it carries the pool's own key, so the tombstone lands on the record's own slot. A record that does not verify gets its refusal; one of another type {error, not_a_node_signed_type}; another node's {error, not_this_nodes_record}; a wire form over 256 KiB, or a signed map whose key, tbs and signature pass 256 KiB together, {error, record_too_large}, refused before the call; and anything else the pool cannot sign, a map whose key, tbs or signature is not a binary included, {error, malformed_record}. Of a signed map, only its key, tbs and signature reach the pool. The tombstone lives until the record has expired plus the clock tolerance.

Types

auth_policy/0

-type auth_policy() :: open | macula_ucan:policy().

handler/0

-type handler() :: fun((term()) -> term()) | {module(), atom()}.

last_disconnect/0

-type last_disconnect() ::
          #{reason := binary(),
            at_ms := integer(),
            expected_node_id => binary(),
            presented_node_id => binary()}.

A seed map may name the node_id its station must prove (a pinned seed), which stands over the pool's expected_node_id option: see connect/2.

opts/0

-type opts() ::
          #{node_identity => macula_node_keys:node_key() | {module(), atom(), [term()]},
            issuer_start =>
                fun((fun(() -> macula_node_keys:node_key()), pid()) -> {ok, pid()} | {error, term()}),
            replication_factor => pos_integer(),
            capabilities => non_neg_integer(),
            alpn => [binary()],
            connect_timeout_ms => pos_integer(),
            dedup_sweep_ms => pos_integer(),
            order_timeout_ms => non_neg_integer(),
            order_max_buffer => pos_integer(),
            admission_sweep_ms => pos_integer(),
            renew_backoff_ms => pos_integer(),
            renew_recheck_ms => pos_integer(),
            realm_trust => #{<<_:256>> => binary()},
            station_discovery =>
                #{enabled => boolean(),
                  procedure => binary(),
                  refresh_ms => pos_integer(),
                  max_links => pos_integer(),
                  giveup_after_ms => pos_integer(),
                  giveup_sweep_ms => pos_integer()},
            link_selection => first_success | random,
            max_seeds => pos_integer(),
            max_direct_links => pos_integer(),
            new_peer_budget => pos_integer(),
            request_admission =>
                #{caller_quota => pos_integer(),
                  share => pos_integer(),
                  reply_bytes => pos_integer(),
                  reply_bytes_total => pos_integer()},
            expected_node_id => <<_:256>>}.

pool/0

-type pool() :: pid().

renew/0

-type renew() :: {module(), atom(), [term()]}.

seed/0

-type seed() ::
          binary() |
          string() |
          #{host := binary() | string(), port := inet:port_number(), expected_node_id => <<_:256>>}.

stations/0

-type stations() :: all | [<<_:256>>, ...].

How a registration renews its chain (D32): apply(M, F, [Pool | A]) answers {ok, Spec} or {error, Reason}.

status/0

-type status() ::
          #{seeds := [seed()],
            healthy_links := non_neg_integer(),
            failed_links := non_neg_integer(),
            self_node_id := <<_:256>>,
            subscriptions := non_neg_integer(),
            replication_factor := pos_integer(),
            pubsub_gap_skips := non_neg_integer(),
            refused_dials :=
                #{too_many_direct_links | new_peer_budget_spent | unusable_seed |
                  link_start_waits_for_issuer | seed_without_expected_node_id | pin_tls_cert_refused =>
                      pos_integer()},
            issuer_restarts := non_neg_integer(),
            issuer_losses := non_neg_integer()}.

Per-link view returned by links/1. One entry per configured seed that currently has a spawned link worker. node_id is the peer station's pubkey (undefined until CONNECT/HELLO completes); host is the dial host parsed from the seed. last_disconnect is why this seed's link last went down, kept across the respawn that replaced it, or undefined if it never has: see last_disconnect().

stream_handler/0

-type stream_handler() :: fun((pid(), term()) -> any()).

Functions

advertise(Pool, Realm, Procedure, Handler)

-spec advertise(pool(), <<_:256>>, binary(), handler()) -> ok | {error, term()}.

Register a procedure handler on every healthy link. Stored in pool state so a respawned link registers it again. A caller reaches this provider only through a procedure_advertisement record that names it; registering the handler publishes none. Returns ok when at least one link accepted the registration. A handler that answers {error, Text} with a binary or a printable charlist sends that text to its caller, up to 256 bytes of it; any other error reason reaches the caller as its name only.

advertise(Pool, Realm, Procedure, Handler, Policy)

-spec advertise(pool(), <<_:256>>, binary(), handler(), auth_policy()) -> ok | {error, term()}.

Advertise with an auth policy -- see auth_policy() above for the full set (open | {ucan_required, IssuerNodeId} | {realm_member_required, RealmKeyId, RequiredCan}).

advertise(Pool, Realm, Procedure, Handler, Policy, EncodedAd)

-spec advertise(pool(),
                <<_:256>>,
                binary(),
                handler(),
                auth_policy(),
                macula_station_link:advertisement() | undefined) ->
                   ok | {error, term()}.

As advertise/5, with the provider advertisement (the resolved D25 authorization included) fanned out to every link as an ADVERTISE frame. Ad is an advertisement spec each link signs per send, naming its own station (what the facade passes), or a pre-signed advertisement's wire form every link sends as it is. The facade resolves the authorization before calling here; undefined registers the handler locally and sends no frame.

advertise(Pool, Realm, Procedure, Handler, Policy, EncodedAd, Stations)

-spec advertise(pool(),
                <<_:256>>,
                binary(),
                handler(),
                auth_policy(),
                macula_station_link:advertisement() | undefined,
                stations()) ->
                   ok | {error, term()}.

As advertise/6, registered on the links to Stations only, each named by the node_id its link pins, or on every link for all. A station the pool holds no link to is refused as {error, {station_not_linked, StationNodeId}}, and nothing is registered or kept. A link respawned later registers the procedure again only when its station is one of Stations.

advertise(Pool, Realm, Procedure, Handler, Policy, EncodedAd, Stations, Renew)

-spec advertise(pool(),
                <<_:256>>,
                binary(),
                handler(),
                auth_policy(),
                macula_station_link:advertisement() | undefined,
                stations(),
                renew() | undefined) ->
                   ok | {error, term()}.

As advertise/7, renewing the chain the spec carries (D32). A procedure_delegation lives 30 minutes, so at a third of the spec's remaining life the pool calls apply(M, F, [Pool | A]) in a worker of its own, which answers {ok, Spec} or {error, Reason}, and registers the fresh spec on the same stations. A failure or a crash is retried on a backoff that never passes the spec's not_after; past it the pool logs at error level, naming the procedure and the last reason, and asks again every renew_recheck_ms, so a re-grant revives the provider without a restart. A spec without a chain (own namespace, a pre-signed advertisement, undefined) is not renewed, and neither is one given undefined. An unadvertise, or another advertise of the procedure, supersedes a renewal in flight: its answer is dropped.

call_linked_station(Pool, Realm, Procedure, Payload, TimeoutMs)

-spec call_linked_station(pool(), <<_:256>>, binary(), term(), 1..600000) ->
                             {ok, term()} | {error, term()}.

Issue a CALL for a procedure the pool's linked stations serve themselves, such as _dht.*: first success across the pool's healthy links, each CALL targeting the station its link is connected to. It moves on to the next link only when the CALL never went out on the one before (macula_station_link:not_sent/1). Returns {error, no_healthy_station} when no link has completed its CONNECT/HELLO handshake. A procedure a provider serves is called through macula:call/5, which resolves the provider.

Realm is per-call (32 bytes). TimeoutMs is from 1 ms to ten minutes, the deadline window a provider accepts.

call_station(Pool, Station, Target, Realm, Procedure, Payload, TimeoutMs, UcanToken, LinkOpts, DialTimeoutMs, Seal)

-spec call_station(pool(),
                   seed(),
                   <<_:256>>,
                   <<_:256>>,
                   binary(),
                   term(),
                   1..600000,
                   binary(),
                   map(),
                   pos_integer(),
                   clear | {sealed_to, binary()}) ->
                      {ok, term()} | {error, term()}.

Issue a CALL to Target, a provider's node_id, at ONE specific station, dialing it directly even if it is not in the pool's seed set. Station is a seed URL (e.g. <<"quic://[::1]:4433">>). The pool ensures a link to it (reusing an existing one, or dialing and monitoring a new one exactly like a seed), waits for the handshake within the deadline, and calls through that link; the station delivers the CALL to the provider Target names. This is the direct-dial data path: resolve a provider's serving_station to its endpoint, then reach it in one hop here, with no mesh relay.

UcanToken is a capability token for a gated provider (empty: none). LinkOpts names the station THIS dial must prove, expected_node_id: the pool's own connect/2-time expectation applies to every link it dials, unworkable for direct dial, whose station is known only once resolved. It applies only when a NEW link is dialed for Station; an already-connected link keeps the identity it proved. pin_tls_cert => true and verify are refused, see macula:call_station/8. At most DialTimeoutMs of TimeoutMs waits for a fresh link's handshake, and the CALL gets what remains: {error, not_connected} comes back after it, before any CALL was sent, so a direct-dial caller can move on.

Seal is {sealed_to, Key}, the provider's KEM key as carried, or clear (E2E design §5.1; see macula_station_link:call/8). It is explicit: this pool has no default and makes no decision of its own. macula:call_station/7,8 decides it from signed state (macula:call_seal/5).

call_station(Pool, Station, Target, Realm, Procedure, Payload, TimeoutMs, UcanToken, LinkOpts, DialTimeoutMs, Seal, Report)

-spec call_station(pool(),
                   seed(),
                   <<_:256>>,
                   <<_:256>>,
                   binary(),
                   term(),
                   1..600000,
                   binary(),
                   map(),
                   pos_integer(),
                   clear | {sealed_to, binary()},
                   boolean()) ->
                      {ok, term()} | {ok, term(), macula_station_link:report()} | {error, term()}.

As call_station/11. Reporttrue asks for the call's seal report: a result then comes back as {ok, Result, Report} (see macula_station_link:call/9); an error is returned as it is.

call_stream_station(Pool, Station, Target, Realm, Procedure, Args, Opts, Seal)

-spec call_stream_station(pool(),
                          seed(),
                          <<_:256>>,
                          <<_:256>>,
                          binary(),
                          term(),
                          map(),
                          clear | {sealed_to, binary()}) ->
                             {ok, pid()} | {error, term()}.

Open a streaming RPC to Target, a provider's node_id, by DIALING a specific station directly (direct-dial). The streaming analogue of call_station/11: ensure (reuse or dial) a link to Station, await the handshake, then open the stream there, naming Target. Opts may set dial_timeout_ms (default 10_000) for the dial and handshake, plus any stream option (e.g. mode). Opts also names the station this dial must prove, expected_node_id, as call_station/11 does. It is kept apart as the dial's own option, so it reaches ensure_link/3 and not the stream open. Seal is how the STREAM_OPEN and its stream go, clear or {sealed_to, KemKey}, decided by the caller from signed state (macula:call_stream_station/7 decides it), never defaulted.

child_spec(Id, Seeds, Opts)

-spec child_spec(term(), [seed()], opts()) -> supervisor:child_spec().

OTP child spec — drop the pool into a caller's supervision tree. Id is the supervisor child id. A supervisor keeps the spec for its child's life, so the spec names how to load the node identity key and never holds the key: give node_identity as a loader {Module, Function, Args} that returns {ok, Key}. Args say where the key is, such as a file name, and never hold the key, because a supervisor that fails to start the pool logs the spec, Args included. A key, or a function that could hold one, given here raises {node_identity, loader_required}.

close(Pool)

-spec close(pool()) -> ok.

Stop the pool. Every subscriber receives a final {macula_event_gone, SubRef, pool_closed} message; every link terminates with the pool.

code_change(OldVsn, S, Extra)

connect(Seeds, Opts)

-spec connect([seed()], opts()) -> {ok, pool()} | {error, term()}.

Spawn a pool with one link per seed. Returns immediately; link handshakes complete asynchronously. Publish/subscribe block until at least one link is connected (or fail with {error, {transient, no_healthy_station}} on the publish path). A node with no crypto profile, or a node_identity that is not an identity key in the node's profile, starts no pool: the refusal is returned and no link is dialed. Nor does a seed that names no node_id it expects, in the seed or in the expected_node_id option: the start returns {error, {seeds, expected_node_id_required}}. A verify key, in the options or on a seed, returns {error, {seeds, {verify, one_verification_mode}}}: there is one verification mode and it is not the caller's to pick.

ensure_station_link(Pool, Station, LinkOpts, TimeoutMs)

-spec ensure_station_link(pool(), seed(), map(), pos_integer()) -> {ok, pid()} | {error, term()}.

A pool link to Station: a live one the pool holds, else one it dials, pinned to the station node_id in the seed or in LinkOpts (expected_node_id), and connected within TimeoutMs. The pool owns and monitors it, respawns it, and ends it when the pool ends. Answers {error, not_connected} when no handshake completes in time. Called through macula:ensure_station_link/4.

format_status(Status)

handle_call(Req, From, State)

handle_cast(Msg, State)

handle_info(Other, S)

init(_)

links(Pool)

-spec links(pool()) -> {ok, [link_info()]}.

Per-link snapshot of the pool — one link_info() per configured seed that currently has a spawned link worker. Unlike status/1 (which only aggregates counts), this exposes each link's node_id (peer station pubkey), dial host, pid, and connected flag, so a caller can resolve a specific station (by pubkey or hostname) to its link and address it directly.

Answered from the pool's own state: each link's connectedness and station come from the link's notice at handshake, and no link is asked.

publish(Pool, Realm, Topic, Payload, Opts)

-spec publish(pool(), <<_:256>>, binary(), term(), map()) -> ok | {error, term()}.

Publish a frame to replication_factor currently-connected links. Partial success = success. Realm is per-call (32 bytes) and identical across every replicated link — a wrong Realm here blackholes the publish on every selected station alike, regardless of replication_factor; see ?DEFAULT_REPLICATION's own doc for what raising the factor does and does not protect against.

The payload is checked for wire admissibility HERE, in the caller's process, before the pool is touched. Downstream the send is a gen_statem:cast into a shared peering connection that encodes without a try/catch, so an unrepresentable term would kill that connection and every other producer's in-flight traffic with it, asynchronously, after this function had already answered ok. Checking first is what makes the ok falsifiable.

Returns {error, {unsupported_payload_type, Type, Path}} naming the offending value and where it sits in the term. Floats are the common case: scale them to integers (micro-units) or send binary strings. A topic over 512 bytes or not UTF-8 is refused first, as {error, {text_too_long, topic}} or {error, {invalid_text, topic}}.

realm_key(Pool, RealmId)

-spec realm_key(pool(), <<_:256>>) -> {ok, binary()} | none.

The realm key the pool pinned for RealmId when it started, from its realm_trust option, or none. Direct dial checks an org namespaced advertisement's authorization against this key alone.

remember_resolved(Pool, RealmId, Procedure, Candidate, TtlMs)

-spec remember_resolved(pool(), <<_:256>>, binary(), map(), non_neg_integer()) -> ok.

Remember Candidate as the station that answered Procedure in RealmId, usable for TtlMs more milliseconds.

A cast, because remembering must not cost the call that earned it any latency, and because losing one is only a lost head start.

resolved_candidate(Pool, RealmId, Procedure)

-spec resolved_candidate(pool(), <<_:256>>, binary()) -> {ok, map(), seed()} | none.

The station that last answered Procedure in RealmId from this pool, as a direct-dial candidate plus the seed the pool's own live link to that station is keyed by, or none.

A HEAD START, NEVER AN ANSWER. It is handed back only while three things hold together: an entry was remembered, its horizon has not elapsed, and the pool STILL HOLDS A LIVE LINK to that station. The live link is the evidence: a link either exists or it does not, so unlike a signed endpoint record up to five minutes old it cannot be stale. Direct dial resolves through the DHT exactly as before whenever this answers none, and also whenever the candidate it returns fails.

⚠ THE LINK IS MATCHED BY NODE ID, NOT BY NAME. find_seed_by_node_id/2 asks each live link who its handshake peer is, so the identity a direct-dial expected_node_id pin would have checked has already been checked here, before the seed is named. Naming the link afterwards by the key the pool itself holds it under is what makes ensure_link/3 match it on its first lookup instead of dialling; the name is how the call REACHES the link, and the node id is what makes it the RIGHT one.

sign_domain_record(Pool, Record)

-spec sign_domain_record(pool(), macula_record:m_record()) ->
                            {ok, macula_record:m_record()} |
                            {error,
                             not_a_domain_type | invalid_subject | lifetime_too_long |
                             lifetime_reversed | record_too_large | malformed_record}.

Sign a domain record (tags 0x20 to 0xFF) as this node, with the pool's node identity key, in the pool's own process, and return the signed record, stored under this node's key id with its subject when it has one. Build it with macula_record:envelope/3. The pool stamps it with a new version and created_at, keeping the lifetime it was built with. Only the record's type, created_at, expires_at, payload and subject reach the pool, and each of these is refused before the call: a type outside 0x20 to 0xFF, {error, not_a_domain_type}; a subject that is not a non-empty binary, {error, invalid_subject}; a lifetime past the domain maximum of 7 days, or running backwards, {error, lifetime_too_long} or {error, lifetime_reversed}, never shortened to fit; a payload and subject over 256 KiB together, {error, record_too_large}; and a term that is no domain record, {error, malformed_record}. A signed record that would pass 256 KiB is {error, record_too_large} from the pool. Withdraw a domain record with withdraw_node_record/3.

sign_node_record(Pool, Record)

-spec sign_node_record(pool(), macula_record:m_record()) ->
                          {ok, macula_record:m_record()} |
                          {error,
                           not_a_node_signed_type | key_id_mismatch | lifetime_too_long |
                           lifetime_reversed | record_too_large | malformed_record}.

Sign a record this node signs about itself with the pool's node identity key, in the pool's own process, and return the signed record: the node record, a procedure advertisement or a content announcement that names this node. The pool stamps it with a new version and created_at, keeping the lifetime it was built with. The key never leaves the pool, so a caller never holds it. Only the record's type, created_at, expires_at and payload reach the pool. A record of another type, a tombstone included, is {error, not_a_node_signed_type}; one that names another node {error, key_id_mismatch}; one whose lifetime passes its type's maximum or runs backwards {error, lifetime_too_long} or {error, lifetime_reversed}; a payload over 256 KiB, refused before the call, or a signed record that would pass 256 KiB {error, record_too_large}; a record with a subject, which no type a node signs about itself carries, refused before the call, and anything else the pool cannot sign {error, malformed_record}.

sign_node_record(Pool, Record, Opts)

-spec sign_node_record(pool(), macula_record:m_record(), map()) ->
                          {ok, macula_record:m_record()} |
                          {error,
                           not_a_node_signed_type | key_id_mismatch | lifetime_too_long |
                           lifetime_reversed | record_too_large | malformed_record | not_after_passed}.

As sign_node_record/2, bounded by Optsnot_after (a Unix millisecond). The pool judges the bound on its own clock: one already passed is {error, not_after_passed}; one before the record's lifetime runs out ends the record at the bound; one after keeps the built lifetime. The refusals of sign_node_record/2 stand under a bound. Opts without not_after signs as sign_node_record/2 does, and a not_after that is not an integer raises function_clause in the caller.

status(Pool)

-spec status(pool()) -> {ok, status()}.

Aggregate health snapshot of the pool. A single round-trip to the pool's gen_server, answered from the pool's own state: no link is asked, so a busy link delays nothing. Suitable for /health or /status endpoints.

Counts:

  • healthy_links — links whose worker pid is alive and whose CONNECT/HELLO handshake has completed. That is necessary for a call and not sufficient: it says nothing about any procedure. The first call through the pool still resolves the procedure's advertisement and, for a station it has no link to yet, dials one (measured on macula 12.4.0 and again on 12.5.1: about 0.45 s for that first call, 150-170 ms warm; #18). Wait on the call you need, not on this count.
  • failed_links — every other configured seed (link not yet spawned, dead, or still handshaking).

subscribe(Pool, Realm, Topic, Subscriber, Opts)

-spec subscribe(pool(), <<_:256>>, binary(), pid(), map()) ->
                   {ok, reference()} |
                   {error,
                    {text_too_long | invalid_text, topic} |
                    {invalid_option, group | distributor | ucan_token} |
                    {group, macula_group_keyring:reason()}}.

Subscribe Subscriber to (Realm, Topic). The pool subscribes every currently-spawned link and dedupes inbound events before fan-out. Returns {ok, SubRef}, or the topic's refusal when a SUBSCRIBE cannot carry it (over 512 bytes, or not UTF-8); Subscriber receives {macula_event, SubRef, Topic, Payload, Meta} for each delivered event and {macula_event_gone, SubRef, Reason} once when the pool closes or the subscriber pid dies.

terminate(Reason, State)

unadvertise(Pool, Realm, Procedure)

-spec unadvertise(pool(), <<_:256>>, binary()) -> ok.

Drop a previously-advertised procedure on every healthy link and remove it from the pool's replay state. Idempotent.

unadvertise_stream(Pool, Realm, Procedure)

-spec unadvertise_stream(pool(), <<_:256>>, binary()) -> ok.

Drop a streaming procedure on every healthy link and remove it from the pool's replay state. Idempotent.

unsubscribe(Pool, SubRef)

-spec unsubscribe(pool(), reference()) -> ok.

Drop a subscription. Idempotent — unknown SubRef is a no-op. The wire-level link subscription persists for the pool's lifetime (one wire sub per (Realm, Topic) multiplexed across local consumers); Phase 4 will tighten this.

withdraw_node_record(Pool, Withdrawn, Reason)

-spec withdraw_node_record(pool(), macula_record:m_record() | binary(), macula_record:reason()) ->
                              {ok, macula_record:m_record()} |
                              {error,
                               not_this_nodes_record | not_a_node_signed_type | lifetime_too_long |
                               lifetime_reversed | record_too_large | malformed_record |
                               macula_record:refusal()}.

Sign a tombstone that withdraws a record this node signed, with the pool's node identity key, in the pool's own process. The pool first verifies the record, as its wire form or its signed map, under its profile, and withdraws it only when it is of a type a node signs about itself or a domain type, and it carries the pool's own key, so the tombstone lands on the record's own slot. A record that does not verify gets its refusal; one of another type {error, not_a_node_signed_type}; another node's {error, not_this_nodes_record}; a wire form over 256 KiB, or a signed map whose key, tbs and signature pass 256 KiB together, {error, record_too_large}, refused before the call; and anything else the pool cannot sign, a map whose key, tbs or signature is not a binary included, {error, malformed_record}. Of a signed map, only its key, tbs and signature reach the pool. The tombstone lives until the record has expired plus the clock tolerance.