macula_client (macula v11.4.0)

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.

Summary

Types

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.

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, Issuer} | {realm_member_required, RealmDid, RequiredCan}).

As advertise/5, with the pool-signed provider advertisement (EncodedAd, the resolved D25 authorization included) fanned out to every link as an ADVERTISE frame. The facade resolves the authorization before calling here; a undefinedEncodedAd registers the handler locally and sends no frame (legacy behaviour).

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 pool-signed provider advertisement (EncodedAd, the resolved D25 authorization included) fanned out to every link as an ADVERTISE frame. undefined registers the handler locally and sends no frame (legacy behaviour).

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/7, presenting a capability token (UCAN) to a gated provider. Empty token = none. Slice 7b.

As call_station/8, with a per-call TLS trust override for THIS dial only — verify (webpki | none), expected_node_id (pin the station's node_id), and/or pin_tls_cert (false to enforce that pin at the application layer only — see macula_peering_conn:connect_opts() — needed against a station whose TLS is terminated by a PKI unrelated to its macula identity, e.g. production behind Let's Encrypt) in LinkOpts. The pool's own connect/2-time verify/expected_node_id are fixed at connect time and apply uniformly to every link the pool dials (seeds and every call_station target alike) — unworkable for direct-dial, whose whole point is reaching a station not known until resolved at call time. This lets a direct-dial caller pin trust to the specific pubkey a signed DHT record just resolved, without weakening (or needing to know in advance) the pool's default verification for its other links. Only applies when a NEW link is dialed for Station — an already-connected link keeps whatever trust it was dialed under.

As call_station/9, waiting at most DialTimeoutMs of TimeoutMs for a freshly-dialed link's handshake; the CALL gets whatever remains of TimeoutMs. {error, not_connected} then comes back after DialTimeoutMs, before any CALL was sent, so a direct-dial caller can move on to another station within its own deadline.

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/7: 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 carries the per-call TLS trust override for this dial: verify, expected_node_id, pin_tls_cert, same as call_station/8. They are kept apart as the dial's own options, so they reach ensure_link/3 and not the stream open.

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}}.

As pick_connected_link/1, but for a SPECIFIC station — reuse a live link to Station or dial (and wait up to TimeoutMs for the handshake on) a fresh one, per-call trust-overridable via LinkOpts (verify / expected_node_id / pin_tls_cert, mirroring call_station/9). This is direct-dial's content-transfer primitive: the returned pid is pinned for a whole put_content/get_content dedicated-stream transfer exactly like pick_connected_link/1, just against a caller-resolved station instead of whichever pool link is already up.

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.

Pick one currently-connected link and return its pid, without issuing a call. For a caller that needs to pin ONE link across a sequence of related calls — a dedicated QUIC stream, opened once on the returned pid (via the internal station-link module's content-stream API), only isolates one link's traffic, so every call in the sequence must go over that same link. call_linked_station/5 picks fresh per call (call_first_success/5) and is the wrong primitive for that.

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.

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. Single round-trip to the pool's gen_server plus one is_connected probe per spawned link (each capped at 1s). Suitable for /health or /status endpoints; not for hot-loop polling.

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 |
          {ucan_required, macula_identity:pubkey()} |
          {realm_member_required, macula_identity:pubkey(), binary()}.

handler/0

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

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(),
            realm_trust => #{<<_:256>> => binary()},
            station_discovery =>
                #{enabled => boolean(),
                  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()}}.

pool/0

-type pool() :: pid().

seed/0

-type seed() :: binary() | string() | #{host := binary() | string(), port := inet:port_number()}.

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 =>
                      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.

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, Issuer} | {realm_member_required, RealmDid, RequiredCan}).

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

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

As advertise/5, with the pool-signed provider advertisement (EncodedAd, the resolved D25 authorization included) fanned out to every link as an ADVERTISE frame. The facade resolves the authorization before calling here; a undefinedEncodedAd registers the handler locally and sends no frame (legacy behaviour).

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)

-spec call_station(pool(), seed(), <<_:256>>, <<_:256>>, binary(), term(), 1..600000) ->
                      {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.

Returns {error, not_connected} if the link does not complete its handshake before the deadline.

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

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

As call_station/7, presenting a capability token (UCAN) to a gated provider. Empty token = none. Slice 7b.

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

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

As call_station/8, with a per-call TLS trust override for THIS dial only — verify (webpki | none), expected_node_id (pin the station's node_id), and/or pin_tls_cert (false to enforce that pin at the application layer only — see macula_peering_conn:connect_opts() — needed against a station whose TLS is terminated by a PKI unrelated to its macula identity, e.g. production behind Let's Encrypt) in LinkOpts. The pool's own connect/2-time verify/expected_node_id are fixed at connect time and apply uniformly to every link the pool dials (seeds and every call_station target alike) — unworkable for direct-dial, whose whole point is reaching a station not known until resolved at call time. This lets a direct-dial caller pin trust to the specific pubkey a signed DHT record just resolved, without weakening (or needing to know in advance) the pool's default verification for its other links. Only applies when a NEW link is dialed for Station — an already-connected link keeps whatever trust it was dialed under.

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

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

As call_station/9, waiting at most DialTimeoutMs of TimeoutMs for a freshly-dialed link's handshake; the CALL gets whatever remains of TimeoutMs. {error, not_connected} then comes back after DialTimeoutMs, before any CALL was sent, so a direct-dial caller can move on to another station within its own deadline.

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

-spec call_stream_station(pool(), seed(), <<_:256>>, <<_:256>>, binary(), term(), map()) ->
                             {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/7: 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 carries the per-call TLS trust override for this dial: verify, expected_node_id, pin_tls_cert, same as call_station/8. They are kept apart as the dial's own options, so they reach ensure_link/3 and not the stream open.

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}}.

ensure_station_link(Pool, Station, LinkOpts, TimeoutMs)

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

As pick_connected_link/1, but for a SPECIFIC station — reuse a live link to Station or dial (and wait up to TimeoutMs for the handshake on) a fresh one, per-call trust-overridable via LinkOpts (verify / expected_node_id / pin_tls_cert, mirroring call_station/9). This is direct-dial's content-transfer primitive: the returned pid is pinned for a whole put_content/get_content dedicated-stream transfer exactly like pick_connected_link/1, just against a caller-resolved station instead of whichever pool link is already up.

format_status(Status)

handle_call(Req, From, S)

handle_cast(Msg, S)

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.

One is_connected/1 + peer_node_id/1 probe per spawned link (each capped at 1s). Not for hot-loop polling.

pick_connected_link(Pool)

-spec pick_connected_link(pool()) -> {ok, pid()} | {error, no_healthy_station}.

Pick one currently-connected link and return its pid, without issuing a call. For a caller that needs to pin ONE link across a sequence of related calls — a dedicated QUIC stream, opened once on the returned pid (via the internal station-link module's content-stream API), only isolates one link's traffic, so every call in the sequence must go over that same link. call_linked_station/5 picks fresh per call (call_first_success/5) and is the wrong primitive for that.

Selection matches call_first_success/5's ordering (first connected link wins) so behaviour is unsurprising relative to the existing pool-routed path.

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.

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. Single round-trip to the pool's gen_server plus one is_connected probe per spawned link (each capped at 1s). Suitable for /health or /status endpoints; not for hot-loop polling.

Counts:

  • healthy_links — links whose worker pid is alive and whose CONNECT/HELLO handshake has completed.
  • 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}}.

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.