Macula SDK — Pub/Sub Guide

View Source

Topic-based messaging over the relay mesh.

PubSub across Two Stations

Audience: Erlang/OTP applications publishing or subscribing to events. Building something the wrappers below don't fit? See PUBSUB_PROTOCOL.md for the raw macula:subscribe/4,5 / macula:publish/4,5 primitives underneath.


Overview

A subscriber subscribes to a topic and receives every matching event; a publisher publishes an event to a topic — macula_subscriber and macula_publisher, an addressable pid you can monitor and cancel.

Subscribing and publishing are independent capabilities, unlike RPC's request/response or a single content transfer — an application might do only one, the other, or both. Every operation on either side names an explicit realm and topic; see Three core ideas, below, before writing your first macula_topic:app_fact/6 call.

See Supervised wrappers, right below.


Supervised wrappers: macula_subscriber / macula_publisher

macula:subscribe/4,5 delivers events as raw messages to whatever pid you pass it, and macula:publish/4,5 is a plain blocking call — neither has an addressable pid you can supervise, monitor, or cancel from outside. macula_subscriber and macula_publisher wrap the same two primitives as proper OTP behaviours. Only the publish side announces itself on the mesh — macula_publisher publishes pubsub.publish_started_v1 / pubsub.publish_completed_v1 around each transfer; macula_subscriber has no mesh-fact equivalent, since a subscription has no single "done" moment to announce.

Subscriber side — start_link/6 opens the subscription in its own init/1 and threads macula_event / macula_event_gone dispatch into Module:handle_event/4 for you — no hand-rolled handle_info clauses needed (see PUBSUB_PROTOCOL.md for the manual pattern this replaces, if you're building something the wrapper doesn't fit). handle_event/4 receives Topic, Payload, and Meta — a map carrying delivery context:

The Payload arrives in wire form (macula_frame:to_wire/1): map keys are {text, K} tuples, atom values are {text, V}, and undefined is null. Normalize it with macula_record:decode_payload/1 (deep), or read single fields with macula_record:payload_field/2, before matching on business keys.

KeyTypeMeaning
realm<<_:256>>Realm tag (matches the subscribe call)
publisher<<_:256>>The publisher's node_id (the original publisher, not the relay)
seqnon_neg_integer()Per-publisher monotonic sequence
published_atnon_neg_integer()When the publisher published it, in milliseconds since the epoch
delivered_viaplumtree | directHow this copy reached the station link
publication_hash<<_:384>>SHA-384 of the publication's signed tbs; the pool delivers each hash once
expires_atnon_neg_integer()The last moment, in milliseconds, a verifier accepts the publication
-module(my_orders_listener).
-behaviour(macula_subscriber).
-export([init/1, handle_event/4]).

init(_Args) -> {ok, #{}}.

handle_event(_Topic, Payload, _Meta, State) ->
    logger:notice(#{order_placed => Payload}),
    {noreply, State}.
Topic = macula_topic:app_fact(Realm, my_org, my_app,
                              <<"orders">>, <<"placed">>, 1),
{ok, Pid} = macula_subscriber:start_link(my_orders_listener, Pool, Realm,
                                         Topic, [], #{sources => members}).

Opts passes straight through to macula:subscribe/5, so sources is required here as it is there — the delivery option covered in Delivery ordering, below, works exactly the same way whether you call it raw or through the wrapper.

A macula_event_gone for this subscription stops the sink with that reason (see When the subscription ends, below). To stop receiving events deliberately, stop the sink itself (gen_server:stop/1, or let its supervisor terminate it) — the pool monitors the subscriber pid directly and drops the wire-level subscription automatically once it's gone, the same cleanup an explicit unsubscribe gives a raw caller.

Publisher side — start_link/5,6 returns immediately with a pid; the publish runs in a linked worker and the outcome reaches Module:handle_published/2:

-module(status_publisher).
-behaviour(macula_publisher).
-export([init/1, handle_published/2]).

init(Parent) -> {ok, Parent}.

handle_published(Result, Parent) ->
    Parent ! {published, Result},
    {stop, normal, Parent}.
{ok, Pid} = macula_publisher:start_link(status_publisher, Pool, Realm,
                                        Topic, Payload, self()).
ResultMeaning
okAt least one link accepted the PUBLISH frame
{error, {transient, no_healthy_station}}The pool has zero spawned links — caller may retry
{error, _}Other failures (validation, etc.)

Partial success counts as success. With replication_factor > 1, the publish resolves as soon as the first selected link accepts the frame. Subsequent links are best-effort.

macula_publisher:cancel/1 stops it before the publish resolves, delivering outcome => cancelled in the pubsub.publish_completed_v1 mesh fact — same shape as macula_feeder's sharing.put_started_v1 / sharing.put_completed_v1.

start_link/5,6 returns as soon as the publisher is running. The start announcement and the publish follow, so the caller never waits on the pool. With the defaults, each publish costs three frames: the payload and the two announcements. A publisher that sends many facts, such as telemetry, passes announce => false to start_link/7 and sends only the payload.

A subscriber ends when its subscription ends. That is macula_event_gone when the pool closes cleanly, or {pool_down, Reason} when the pool process dies without saying so, because it was killed or taken down by a link. Both are abnormal exits, so a supervisor restarts the subscriber against the pool that is current. A process that calls macula:subscribe/4,5 itself should monitor the pool the same way.

Unlike macula_subscriber, macula_publisher:start_link/6's last argument is Args (for Module:init/1) — there's no way to pass macula:publish/5's own Opts (e.g. timeout_ms) through the wrapper. See PUBSUB_PROTOCOL.md if you need that.


Three core ideas

1. Realm-per-call

Every publish and every subscribe carries an explicit 32-byte realm tag. There is no connect-time default realm. Realms are how the mesh isolates traffic — a subscriber on Realm A never receives an event published to Realm B, even if the topic strings match exactly.

%% Same topic, different realms — totally separate streams.
ok = macula:publish(Pool, RealmA, Topic, PayloadA),
ok = macula:publish(Pool, RealmB, Topic, PayloadB).

Realms are 32-byte binaries. Use macula_realm:id/1 (SHA-256 of the human-readable realm name) or your own realm registry to derive them.

2. Topics describe event types, not entities

Non-negotiable for scalable pub/sub.

Topic   = WHAT happened (event type, immutable)
Payload = WHO/WHERE/WHEN it happened (entity details)
Approach1M sensorsTopicsResult
ID in topic1M sensors1M topicsDHT explosion, memory exhaustion
ID in payload1M sensors1 topicScalable, efficient routing

Wrong (topic explosion):

%% DO NOT DO THIS — entity ID baked into topic
Topic = macula_topic:app_fact(Realm, Org, App,
                              <<"weather">>,
                              <<"manchester_main_street_wind_measured">>, 1),
macula:publish(Pool, Realm, Topic, #{speed => 42.5}).

Right (IDs in payload):

Topic = macula_topic:app_fact(Realm, Org, App,
                              <<"weather">>, <<"wind_measured">>, 1),
macula:publish(Pool, Realm, Topic, #{
    station_id => <<"manchester-main-street">>,
    speed => 42.5,
    unit => <<"km/h">>,
    timestamp => erlang:system_time(millisecond)
}).

3. Topics are built — never hand-typed

%% Always
Topic = macula_topic:app_fact(Realm, Org, App, Domain, Name, Version),

%% Never
Topic = <<"my.realm/my.org/my.app/orders/placed_v1">>.

macula_topic returns a canonical binary that the SDK and stations agree on. Nothing on the send path rejects a hand-rolled string — macula:publish/4,5 and subscribe/4,5 take Topic as an opaque binary and send whatever you pass, so a typo just never matches instead of erroring. Building via macula_topic turns that class of bug into a wrong value your own tests can catch. See Validation in the Topic Naming Guide for exactly what is and isn't enforced.

See TOPIC_NAMING_GUIDE.md for the canonical specification of the five-segment shape and the realm_fact / org_fact / app_fact tier choice.


Who may deliver to your subscription

Every subscription declares its sources: who may deliver facts to it. There is no default — a subscription that names none is refused at subscribe with {error, {sources, required}}, so a service cannot ship one that anyone can deliver to (macula#147).

%% Any publisher the realm admitted.
{ok, Sub} = macula:subscribe(Pool, Realm, Topic, self(), #{sources => members}),

%% A node the organisation `acme` delegated (macula:org_member/4,5).
{ok, Sub2} = macula:subscribe(Pool, Realm, Topic2, self(), #{sources => {org, <<"acme">>}}),

%% Exactly these publishers, and nothing looked up.
{ok, Sub3} = macula:subscribe(Pool, Realm, Topic3, self(), #{sources => {nodes, [NodeId]}}).
SourceAdmitsWhat it costs
{nodes, [NodeId]}exactly the listed publisher node idsnothing: a comparison, and it never expires
membersa publisher whose realm member endorsement is live — the member itself, never a delegateone record lookup per publisher, cached
{org, Org}a node the organisation's directory delegatesone record lookup per publisher, cached
openanyonenothing; an explicit opt-out, and the log says so

It is enforced by your own SDK, on the publisher the delivering link verified, before your handler runs and on both delivery channels. No station can make a fact it relays look like another node's, so this is the one publish-side control that holds even against a station working with a malicious member. It is self-protection, not a grant: nothing is minted, published or administered.

What it is not. Not a read permission: on a sealed group's topic, whoever may read the group key may produce readable events, so use a group's keys for confidentiality and sources for who may speak. And members on a subscription is stricter than members on a call: a member's delegate is admitted to a members procedure, but a subscription admits only the member itself, because the endorsement names the member's own node.

When a lookup is needed, it runs in a worker inside your pool, never blocking delivery, and its verdict is cached under {realm, source, publisher} — never past the record that produced it, refreshed while still in use, so a steady publisher is looked up about once per cache life rather than once per fact. A fact from a publisher whose lookup is still running is held behind that one lookup (bounded, and never past the fact's own expiry) rather than dropped.

Watching it work. macula_client:status/1 keeps three counters, deliberately apart:

CounterMeans
source_refuseda verdict with its reason: the source does not admit that publisher
source_droppeda budget with its reason: the realm's lookup window, the hold cap, an expired hold, or a lookup that could not find out
source_heldhow many facts are waiting behind a lookup right now

Alongside them, source_verdicts (live cached verdicts) and source_lookups (lookups in flight). The bounds themselves are one connect/2 option, subscribe_sources (max_publishers, held_per_key, lookups_per_window, window_ms, cache_ttl_ms, refresh_margin_ms, refusal_ttl_ms, lookup_timeout_ms); a malformed one refuses to start the pool.

In your log. Each subscription logs its sources at subscribe — the alphabet, and the organisation for org — and an open subscription logs as an explicit opt-out, so an operator can read what a running service accepts without reading its source.


When the subscription ends

The only way a live subscription produces a terminal message today is the pool closing — delivered to handle_event/4's raw equivalent as {macula_event_gone, SubRef, pool_closed}, and to a macula_subscriber sink as its own termination with that reason.

A link dying does not end the subscription — the pool logs _macula.client.link_down, schedules a respawn, and re-issues the subscription against the new link once it's up (see Connecting Guide). It may still have cost you events; the next section says when, and how you are told.

After the subscription ends, no further events come for it.


When events may have been missed

The pool carries each subscription on every station link it holds. While one of them carries it, nothing is missed: every publication reached the pool on some link, and the pool's dedup merged the copies. A link that loses its station for less than about a minute also loses nothing, because QUIC resends what was in flight.

A longer outage — a laptop lid closed over lunch, a train through a tunnel — ends the link after two missed liveness probes (30 seconds apart). Whatever the station had written to it since is gone, and the station does not keep it. When that link was the last one carrying the subscription, the pool tells the subscriber, beside its events:

receive
    {macula_delivery_interrupted, SubRef, #{from_ms := From, reason := Reason}} ->
        %% No station carries the subscription since From.
        show_offline(From, Reason);
    {macula_delivery_gap, SubRef, #{from_ms := From, to_ms := To}} ->
        %% A station carries it again; events may have been missed in between.
        resync(From, To)
end.
  • from_ms is the last time any link carrying the subscription heard from its station; to_ms is when a station accepted it again. Both are this node's wall-clock milliseconds.
  • reason names why the last link ended, such as <<"app_liveness_lost">>.
  • A gap comes once per interruption, and only after one. If every station refuses the re-subscription, it stays interrupted and no gap follows; the refusal is logged as _macula.client.registration_refused.
  • Nothing is sent while another link still carries the subscription, at the first subscribe, or after you unsubscribe.
  • A subscriber that joins a subscription the pool already holds while it is interrupted is told the gap too, for a window that began before it joined.
  • On a sealed group subscription, events reach you through the group's opener while the notices come straight from the pool, so a notice can arrive ahead of events the opener still holds. Resyncing on the gap covers both orders.

Answer a gap by resyncing, not by filtering. The times are hints: an event's published_at is the publisher's clock, not yours, and an event can be lost slightly before from_ms. Ask whatever the subscription feeds for its current state, or exchange state with its peers, and merge.

A macula_subscriber module gets the notices through the optional callbacks handle_interrupted/2 and handle_gap/2; a subscribe_callback/5 subscription through the options on_interrupted and on_gap, each a function of the notice's map. Without them, the notice is logged as _macula.client.delivery_interrupted or _macula.client.delivery_gap, and serving goes on.

What this does not cover: events a station refused to write to a link that stayed up because it was too far behind, a subscription a station drops while the link stays up (at a membership token's end), and events lost before they reached any station. The ordered mode's own gap handling, for a missing seq from one publisher, is separate (see Delivery ordering).


Delivery ordering

The delivery option chooses how a single publisher's stream is ordered on the way to your subscriber — pass it in Opts to macula_subscriber:start_link/6 the same way you would to raw macula:subscribe/5. This matters because the mesh sends copies of a fact down several links at once, and the pool dedups to the first arrival — so without ordering, a single publisher's seq 1, 2, 3 can reach you as 1, 3, 2. Each publisher's seq is pool-monotonic and contiguous, which is exactly what makes ordered delivery possible.

ModeBehaviourUse when
ordered (default)Per-publisher FIFO by seq. Out-of-order arrivals are buffered and released in order; a genuinely missing seq is skipped after order_timeout_ms. A copy that arrives after its gap was skipped is still delivered — late, flagged late => true in the meta, and counted (pubsub_late_delivered); only true duplicates are dropped (pubsub_past_dropped).Event / delta streams where order matters.
latest_onlyDeliver only seqs newer than the highest seen for that publisher (drop stale). No buffering, no head-of-line delay.State snapshots — you want the freshest value, not every value.
as_arrivesRaw arrival order. Zero added latency; you order it yourself.You have your own versioning, or you truly do not care.

Ordering state is kept per publisher, and separately for EVENTs whose publisher signature verified and for all others: unsigned EVENTs are ordered among themselves, per publisher, and never affect the order of a verified publisher's EVENTs.

%% default — per-publisher FIFO
{ok, Pid1} = macula_subscriber:start_link(my_orders_listener, Pool, Realm, Topic, [],
                                          #{sources => members}),

%% newest-wins, drop stale
{ok, Pid2} = macula_subscriber:start_link(my_orders_listener, Pool, Realm, Topic, [],
                                          #{sources => members, delivery => latest_only}),

%% raw arrival order (the pre-8.8 behaviour)
{ok, Pid3} = macula_subscriber:start_link(my_orders_listener, Pool, Realm, Topic, [],
                                          #{sources => members, delivery => as_arrives}).

Ordered mode and loss. ordered trades a bounded delay for order: if seq 2 never arrives, the buffer holds 3, 4, … only until order_timeout_ms elapses, then skips the gap and releases them. A skip is not a silent drop: the seqs the skip gave up on are remembered (bounded per publisher — the newest order_max_buffer of them), and a copy of one that arrives afterwards is delivered late, with late => true in the event's meta, and counted as pubsub_late_delivered in macula:status/1. Only true duplicates are dropped, counted as pubsub_past_dropped. What no buffer can do is invent a fact the mesh never delivered: those stay visible as pubsub_gap_skips — each skip either turns up late or remains missing. Design mesh facts to be idempotent and version-stamped so an occasional late delivery or skip washes out.

A publisher's first facts. When an ordered subscription first hears from a publisher, or hears from it again after a restart, it does not yet know where that publisher's order starts: copies arrive over several links in any order. It holds that publisher's first facts for up to order_timeout_ms (or until order_max_buffer facts are held), then starts the order at the lowest seq it has seen, so a lower seq that arrives after a higher one is still delivered, in order. A publisher's first facts therefore reach the subscriber up to one order timeout later than its later facts.

Publisher restarts. A publisher's seq is seeded from wall-clock microseconds at start (macula_client for an SDK pool; macula_realm_pubsub_server for a station's own facts, since 10.17.0), so a restart shows up as a jump far wider than any reorder window. Both ordered and latest_only read a jump of more than 10 000 in either direction as a new epoch: the old epoch's buffered tail is released, then delivery continues from the new counter. The backward case matters for any publisher whose counter restarts at 0: without it, every fact after such a restart is dropped as "already delivered" until the counter climbs back over the old watermark, with nothing in macula:status/1 showing it (a live read model went blind for 10+ hours this way in September 2026). A backstep smaller than that is still a late duplicate and is still dropped.

Total order is not offered, by design. ordered restores a single publisher's order (cheap, over ordered transport). It does not impose a total order across different publishers — that would need a single sequencer (a consensus log) that the mesh deliberately does not have. Cross-publisher order is not something a decentralised broadcast can give you; carry a version or timestamp in the fact if a consumer needs to relate two publishers' events.

Pool-level tuning (connect/2 options)

OptionDefaultMeaning
order_timeout_ms250How long an ordered sub waits for a missing seq before skipping the gap, and the longest it holds a new publisher's first facts. Bounds head-of-line delay.
order_max_buffer1024Per-publisher reorder-buffer count cap. Over it, the head gap is skipped early (memory guard for a high-rate publisher gapping). The same count bounds the skipped-seq memory that late delivery uses.

Telemetry — is loss real?

macula:status/1 reports the ordered-delivery ledger:

  • pubsub_gap_skips — per-publisher gaps given up on after the timeout (or at the buffer cap);
  • pubsub_late_delivered — copies of those gaps that arrived afterwards and were delivered late (late => true in the meta);
  • pubsub_past_dropped — arrivals dropped as true duplicates.

Each skip either turns up late or remains genuinely missing, so skips - late_delivered is the loss an ordered subscriber could not fill. A near-zero value means the mesh is delivering and ordered costs you almost nothing; a rising value is the signal to look at delivery, not ordering.

{ok, #{pubsub_gap_skips := Skips,
       pubsub_late_delivered := Late}} = macula:status(Pool).

Dedup and delivery guarantees

Every EVENT's publication is verified before delivery, and one that does not verify is dropped. The dedup key is the publication's hash, the SHA-384 of its signed tbs: the pool guarantees you see each publication at most once, even when it arrives via multiple links (e.g. with replication_factor > 1). Two publications that share a realm, publisher and seq are still two publications, with two hashes. In ordered and latest_only modes the delivery layer additionally uses the seq to order or drop; in as_arrives the dedup layer is the only filter.

  • At-most-once — fire and forget. No publisher-visible ack from subscribers.
  • Per-publisher delivery order — ordered by default at the subscriber (see Delivery ordering above): out-of-order arrivals are buffered and released in seq order, with a genuinely missing seq skipped after order_timeout_ms and a copy that arrives after the skip delivered late, flagged late => true (only true duplicates are dropped). The mesh itself does not guarantee arrival order — a relay spreads one publisher's burst across concurrent verify workers, and a receiver may admit an event by more than one path — the subscriber-side ordered buffer is what turns that into in-order delivery. Opt into as_arrives if you'd rather see raw arrival order and reorder yourself.
  • Cross-publisher ordering — none, by design. Two publishers' events arrive in arbitrary interleaving; see "Total order is not offered, by design" above.
  • Cross-link dedup: the pool dedupes by publication hash and keeps each hash until the publication expires, published_at plus its ttl_ms (at most 1 hour), or 10 minutes without one, plus 5 minutes, after which every verifier refuses it. The pool judges expiry when an event arrives, and records a hash only while a subscription matches it.
  • Cross-station gossip — default since 4.5.0. A daemon connected to station A and a daemon connected to station B see each other's publishes once subscription interest and the fact itself have gossiped between the stations; every hop verifies a publication once, keyed by its hash, before it delivers or forwards it, which is what makes this safe past one hop.

Sealed groups

A publication is signed but, by default, readable by every station it passes through. A sealed group keeps a group's events readable only by the group's members: stations still route and deliver them, and see who published what, where and when, but not the payload.

A group is a topic prefix whose second segment is its org: io.macula/acme/chat is owned by acme, and covers every topic below it on a segment boundary (io.macula/acme/chat/room/said_v1, not io.macula/acme/chatter/...). The org runs one distributor, the procedure acme/group_keys_v1 (macula_group_keys), which hands the group's keys to its members.

%% A member: publish and subscribe under the group. The pool joins it first,
%% pulling the group's key with the token its distributor admits: the org's
%% grant here, or the member's membership UCAN from its realm for a
%% restricted group (below).
Opts = #{group => <<"io.macula/acme/chat">>, ucan_token => OrgGrant},
ok = macula:publish(Pool, Realm, <<"io.macula/acme/chat/room/said_v1">>, Payload, Opts),
%% `sources' is required on every subscription: who may deliver to it
%% (macula#147). A sealed group's key already decides who may speak.
{ok, Sub} = macula:subscribe(Pool, Realm, <<"io.macula/acme/chat/room/said_v1">>, self(),
                             Opts#{sources => {org, <<"acme">>}}),
receive
    {macula_event, Sub, Topic, Payload, #{sealed := 1, seal_key_id := _Epoch}} -> ok;
    {macula_event_unopened, Sub, Topic, #{publisher := _, seal_key_id := _, reason := _}} -> ok
end.

What it rests on. This release builds sealed groups in the Erlang SDK. No confidentiality claim is made for the fleet before the scheme is tested across SDKs and measured (D11); the design names what each guarantee rests on.

  • The key pull is sealed, and so is every event. The member's pull is a sealed call to the distributor's own KEM key, so no station on the path sees an epoch key. Each publisher seals under its own subkey of the epoch key, bound to the publication's realm, topic, publisher, seq and published_at: an event moved to another topic, or replayed with another seq, does not open.
  • The distributor is the org's. A member accepts a distributor only when its advertisement verifies under the chain the realm operator signed (the org directory, then the org's delegation of the node), for exactly <org>/group_keys_v1. distributor => NodeId pins one.
  • Who may read is checked on every pull. The distributor admits a caller only with the token its group's access policy names (the call's own UCAN, below) and a live realm membership, read from the realm's slot at the time of the call, past epochs included. An application keeps a removed set for members it expels: they are refused every epoch at once.
  • Keys rotate. An epoch publishes for 15 minutes (rotate_after_ms), and every holder pulls the next one at a random instant in the last third, so a rotation is spread out rather than a herd. A removed member reads nothing published under an epoch it did not already hold: at most 85 minutes after its removal, plus clock skew. What it already read, it keeps.

Fail closed. A publish or subscribe under a group whose distributor cannot be found, reached or understood, or which refuses the member, fails as {error, {group, Reason}}. A publish that names no group under a prefix this node holds is refused as {error, {confidentiality, {group_held, Prefix}}} rather than sent in the clear. When the org's policy is required, a node holding the group refuses clear events under the prefix on every subscription, and logs the publisher.

An event that cannot be opened is never silent. The subscriber gets {macula_event_unopened, SubRef, Topic, #{publisher, seal_key_id, reason}} once, and nothing of the payload. reason is one of unknown_epoch, epoch_expired, not_authorized, not_a_member, membership_unknown, no_distributor, no_group (a sealed event on a subscription that named no group) and tag_invalid. A subscriber that missed an epoch (offline across a rotation) pulls it by id, at most three unknown ids per publisher per epoch.

Who may read a group. Each group has one access policy, chosen when its distributor starts:

  • org_grant (the default for a group not listed): the org's group_keys grant. It admits its holder to every such group of the org.
  • {realm_capability, RealmKeyId, Mode}, a restricted group: the realm's pubsub/subscribe, in the member's membership UCAN, covering the whole group: mri:topic:P and mri:topic:P/*. A role on P/* alone does not admit, since the group key opens P too. Mode is refusing, or counting (the default), which counts a pull the realm capability would refuse and lets it through when the same token carries the org's grant, so a realm can move members over one by one and flip once the would-be refusals read zero. Counting lets those pulls through: treat a counting group as unprotected until it is flipped. A restricted group needs the required seal policy.

Either policy checks the token's audience against the caller, its expiry at the pull, past epochs included, and its chain. A refusal reaches the member as {error, {group, not_authorized}}. What it bounds, honestly: a member whose role is withdrawn may pull until its membership token expires (the realm caps it at an hour), then reads with the key it already holds for one more rotation; whoever may read a sealed topic may also seal events on it, since the group key is shared; and stations still see who pulls from which distributor, never the keys.

Running a distributor. The org's application starts macula_group_keys:start_link(#{org => <<"acme">>, policy => required, org_key_id => OrgKeyId, realm => Realm, profile => Profile, groups => #{<<"io.macula/acme/orders">> => {realm_capability, RealmKeyId, counting}}, ...}) and advertises its handler/1 as acme/group_keys_v1 with macula_group_keys:advertise_opts/0 merged into its options: the call's own token handed to the handler, which checks it per group, and sealed calls only, so a clear pull is answered sealed_required. macula_group_keys:counts/1 reads a restricted group's pulls by realm, group and outcome. With kem_advertise off those options refuse to advertise at all, rather than advertise a keyless distributor. Epoch keys live in memory only: a restarted distributor starts a new epoch, and a member that never pulled a lost one reports its events unknown_epoch.

The full design, with what each guarantee rests on, is docs/design/DESIGN_E2E_SEALED_PUBSUB.md; the byte-exact payloads and the org grant's verdicts are test/vectors/E2E_SEAL_V1.md, "Sealed groups".


Topic naming reference

Quick reference. Full specification: TOPIC_NAMING_GUIDE.md.

Every topic is exactly five slash-separated segments:

{realm}/{publisher_org}/{publisher_app}/{domain}/{name}_v{N}

Pick a tier based on who owns the topic:

TierBuilderUse when
Realm-levelmacula_topic:realm_fact/4, macula_topic:realm_hope/4Topic owned by the realm itself
Org-levelmacula_topic:org_fact/5, macula_topic:org_hope/5Topic owned by an organization within a realm
App-levelmacula_topic:app_fact/6, macula_topic:app_hope/6Topic owned by an application within an organization

Past tense for facts (order_placed, wind_measured, user_registered). Present tense for hopes (order_place, payment_authorize).

System topics (_mesh.*, _macula.*) are infrastructure-owned and dot-separated. Do not publish to them from app code.


Patterns

Re-subscribe after pool restart

If your supervisor restarts the pool, your subscribers are not automatically re-attached. Either:

  • Restart your subscriber processes (or macula_subscriber sinks) alongside the pool (one supervisor with rest_for_one strategy), or
  • Watch for the subscription ending with pool_closed and re-subscribe to the new pool.

Multiple subscribers on one pool

A single pool can have arbitrarily many local subscribers — whether raw processes or macula_subscriber sinks. The pool issues exactly one wire-level SUBSCRIBE per (Realm, Topic), multiplexes inbound events to every local subscriber for that pair, and dedupes across links.

Backpressure: the delivery contract

Each delivered event is one plain Erlang message, {macula_event, SubRef, Topic, Payload, Meta}, sent to the subscriber pid with !. The pool never blocks on a subscriber and never drops an event for one, so the subscriber's mailbox is the buffer, and it is unbounded: a subscriber slower than its topic holds every event it has not read yet, payload included, until it reads them or dies. Nothing in the pool bounds this for you (macula#64 F14), on purpose: a pool that dropped or waited would lose facts or stall every other subscriber.

What to do on a busy or payload-heavy topic:

  • Read the mailbox in a process that does nothing else, and hand the slow work to others (a pool of workers, or a queue you bound and drop from yourself), so the reader keeps up.
  • If only the newest value matters, subscribe with #{delivery => latest_only}: a stale event is not delivered at all. It still delivers every newer one, so it does not bound the mailbox on its own.
  • Start the reading process with process_flag(message_queue_data, off_heap), so a long queue does not make its garbage collections slow.
  • Watch erlang:process_info(Pid, message_queue_len) if you need an alarm.

Idempotent handlers

The pool delivers each publication at most once, and verifiers refuse a copy that arrives after the publication expired. A publisher that sends the same fact twice still makes two publications, so make handlers idempotent — match on a payload-level key (order id, sensor id + timestamp, etc.) when correctness matters.


Best practices

  1. IDs in payloads, not topics. Always.
  2. Past-tense facts, present-tense hopes. No CRUD verbs (created, updated, deleted).
  3. Build topics via macula_topic — never inline strings.
  4. Include a timestamp — erlang:system_time(millisecond) in every payload.
  5. Keep handlers fast — spawn workers for heavy processing.
  6. Make handlers idempotent: the pool delivers each publication once, but a publisher can send one fact as two publications; don't rely on exactly-once.

Diagnostics

Event topicWhenMeta
_macula.client.link_downA pool link's worker diedseed, pid, reason
_macula.peering.handshake_timeoutA station handshake hung past state_timeoutrole, buf_size, has_stream, timeout_ms

These come through macula_diagnostics:event/2; wire them into your observability layer.


See also