Macula SDK — Pub/Sub Guide
View SourceTopic-based messaging over the relay mesh.
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,5primitives 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.
| Key | Type | Meaning |
|---|---|---|
realm | <<_:256>> | Realm tag (matches the subscribe call) |
publisher | <<_:256>> | The publisher's node_id (the original publisher, not the relay) |
seq | non_neg_integer() | Per-publisher monotonic sequence |
published_at | non_neg_integer() | When the publisher published it, in milliseconds since the epoch |
delivered_via | plumtree | direct | How this copy reached the station link |
publication_hash | <<_:384>> | SHA-384 of the publication's signed tbs; the pool delivers each hash once |
expires_at | non_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()).Result | Meaning |
|---|---|
ok | At 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)| Approach | 1M sensors | Topics | Result |
|---|---|---|---|
| ID in topic | 1M sensors | 1M topics | DHT explosion, memory exhaustion |
| ID in payload | 1M sensors | 1 topic | Scalable, 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]}}).| Source | Admits | What it costs |
|---|---|---|
{nodes, [NodeId]} | exactly the listed publisher node ids | nothing: a comparison, and it never expires |
members | a publisher whose realm member endorsement is live — the member itself, never a delegate | one record lookup per publisher, cached |
{org, Org} | a node the organisation's directory delegates | one record lookup per publisher, cached |
open | anyone | nothing; 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:
| Counter | Means |
|---|---|
source_refused | a verdict with its reason: the source does not admit that publisher |
source_dropped | a budget with its reason: the realm's lookup window, the hold cap, an expired hold, or a lookup that could not find out |
source_held | how 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_msis the last time any link carrying the subscription heard from its station;to_msis when a station accepted it again. Both are this node's wall-clock milliseconds.reasonnames 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.
| Mode | Behaviour | Use 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_only | Deliver 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_arrives | Raw 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)
| Option | Default | Meaning |
|---|---|---|
order_timeout_ms | 250 | How 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_buffer | 1024 | Per-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 => truein 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 —
orderedby default at the subscriber (see Delivery ordering above): out-of-order arrivals are buffered and released inseqorder, with a genuinely missingseqskipped afterorder_timeout_msand a copy that arrives after the skip delivered late, flaggedlate => 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-sideorderedbuffer is what turns that into in-order delivery. Opt intoas_arrivesif 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_atplus itsttl_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 => NodeIdpins 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'sgroup_keysgrant. It admits its holder to every such group of the org.{realm_capability, RealmKeyId, Mode}, a restricted group: the realm'spubsub/subscribe, in the member's membership UCAN, covering the whole group:mri:topic:Pandmri:topic:P/*. A role onP/*alone does not admit, since the group key opensPtoo.Modeisrefusing, orcounting(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 therequiredseal 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:
| Tier | Builder | Use when |
|---|---|---|
| Realm-level | macula_topic:realm_fact/4, macula_topic:realm_hope/4 | Topic owned by the realm itself |
| Org-level | macula_topic:org_fact/5, macula_topic:org_hope/5 | Topic owned by an organization within a realm |
| App-level | macula_topic:app_fact/6, macula_topic:app_hope/6 | Topic 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_subscribersinks) alongside the pool (one supervisor withrest_for_onestrategy), or - Watch for the subscription ending with
pool_closedand 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
- IDs in payloads, not topics. Always.
- Past-tense facts, present-tense hopes. No CRUD verbs
(
created,updated,deleted). - Build topics via
macula_topic— never inline strings. - Include a timestamp —
erlang:system_time(millisecond)in every payload. - Keep handlers fast — spawn workers for heavy processing.
- 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 topic | When | Meta |
|---|---|---|
_macula.client.link_down | A pool link's worker died | seed, pid, reason |
_macula.peering.handshake_timeout | A station handshake hung past state_timeout | role, buf_size, has_stream, timeout_ms |
These come through macula_diagnostics:event/2; wire them into your
observability layer.
See also
- PUBSUB_PROTOCOL.md — the raw primitives underneath, plus how relay-side routing works.
- Connecting Guide — pool model, lifecycle, options
- Topic Naming Guide — canonical topic shape
- Authorization Guide — UCAN/DID identity
macula_pubsub— slice modulemacula_client— pool implementationmacula_subscriber— supervised subscriber behaviour