Macula Mesh — Topic Naming Convention

View Source

Status: Convention, built via the macula_topic module. Not automatically enforced by the SDK — see "Validation" below.


Structure

Every mesh topic (pub/sub) and RPC procedure name follows a single 5-segment format:

{realm}/{publisher}/{publisher}/{domain}/{name}_v{N}

The two {publisher} slots carry different values depending on the ownership tier of the topic. Three tiers exist; one builder per tier.

SegmentSourceExample
realmRuntime config (which mesh)io.macula
publisher × 2Tier-dependent (see below)beam-campus + hecate, or _realm+_realm, etc.
domainBounded contextmpong, licenses, membership
name_vNTopic name + version suffixlobby_opened_v1, chat_to_model_v1

System topics (any leading-underscore prefix — _mesh.*, _dist.*, _dht.*) are exempt from this structure. They are dot-separated, infrastructure-owned, and out of scope for application code.


Three ownership tiers

A topic's tier answers one question: who owns its schema and authority?

TierBuilderTopic shapeOwner
realmrealm_fact/4, realm_hope/4{realm}/_realm/_realm/{domain}/{name}_v{N}The realm authority — concepts shared by every member regardless of org/app (membership, identity, ban)
orgorg_fact/5, org_hope/5{realm}/{org}/_org/{domain}/{name}_v{N}An org — concepts shared by multiple apps within one org (licensing, billing, org-wide settings)
appapp_fact/6, app_hope/6{realm}/{org}/{app}/{domain}/{name}_v{N}A specific app — anything app-internal (game state, RPCs, app-specific events)

Each builder takes the realm name as its first argument; org-tier additionally takes the org; app-tier takes both org and app. The underlying build/6 always emits 5 tokens. Sentinels (_realm, _org) fill elided publisher slots so the parser never branches on length.

Per-app convenience wrappers (e.g. hecate_topics) typically pre-fill realm + org + app constants and expose 3-arg variants — see the Hecate practical guide for the wrapper pattern.

Picking a tier

Walk the decision tree. First "yes" wins.

  1. Could the realm authority publish or revoke this fact at will? (membership, ban, identity-key revocation) → realm
  2. Is the schema something every realm member must trust regardless of which app they run? (cryptographic identity, capability grants signed by the realm) → realm
  3. Is this a concept owned by one org that crosses multiple apps within that org? (commercial licensing, org-wide billing, shared catalog) → org
  4. Is this internal to one app — game state, RPC procedures it advertises, app-specific events?app

When unsure → start at app and promote later. Demoting is harder because subscribers exist.

The reserved sentinels: _realm and _org

Two literal tokens are reserved and may not be used as real org or app names:

  • _realm — fills the org and app slots when the publisher tier is realm
  • _org — fills the app slot when the publisher tier is org

The leading underscore is the marker. Real org and app names must satisfy DNS-label rules (start with [a-z0-9]), so they cannot collide with sentinels.

There is no _app sentinel. App-tier topics fill all 5 slots with real values; nothing is elided.


Hopes and Facts — verb tense determines intent

The name segment reveals intent through verb tense:

TypeVerb TenseWire FormatMeaning
FactPast tense{subject}_{past_verb}_v{N}Something happened
HopePresent tense{verb}_{subject}_v{N}We want something to happen

Facts (pub/sub — things that happened)

%% realm tier
io.macula/_realm/_realm/membership/revoked_v1
io.macula/_realm/_realm/identity/public_key_announced_v1

%% org tier
io.macula/beam-campus/_org/licenses/issued_batch_v1
io.macula/beam-campus/_org/licenses/revoked_v1

%% app tier
io.macula/beam-campus/hecate/mpong/lobby_opened_v1
io.macula/beam-campus/hecate/llm/model_detected_v1
io.macula/acme-org/trader/portfolio/position_closed_v1

Published via macula:publish/5. Subscribed via macula:subscribe/5.

Hopes (RPC — things we want to happen)

%% realm tier
io.macula/_realm/_realm/auth/check_health_v1
io.macula/_realm/_realm/auth/verify_api_key_v1

%% org tier (rare)
io.macula/beam-campus/_org/billing/get_quota_v1

%% app tier
io.macula/beam-campus/hecate/llm/chat_to_model_v1
io.macula/beam-campus/hecate/mpong/join_game_v1
io.macula/acme-org/trader/portfolio/open_position_v1

Advertised via macula:advertise/5. Called via macula:call/5.


Rules

  1. Exactly 5 /-separated segments — never fewer, never more, never dots.
  2. Segments are snake_case — lowercase, underscores. Hyphens allowed in org/app names per DNS-label rules.
  3. Name MUST end with _v{N} — versioned from day one. Bump on breaking changes.
  4. Past tense = fact, present tense = hope — the tense IS the type.
  5. IDs in payload, NEVER in topic — prevents topic explosion (1M sensors = 1 topic, not 1M).
  6. No CRUD verbscreated, updated, deleted are forbidden. Use business verbs.
  7. Realm is runtime — comes from mesh connection config, never hardcoded in source.
  8. {org}/{app} is build-time — comes from the application's manifest.
  9. No inline topic strings — always call a builder. CI greps for and rejects inline construction.
  10. Tier matches authority, not publisher — a daemon publishing a realm-owned fact still uses realm_fact.

API

The macula_topic module exposes one builder per tier, plus the parser:

%% Realm tier — _realm fills both publisher slots
macula_topic:realm_fact(<<"io.macula">>, <<"membership">>, <<"revoked">>, 1).
%% → <<"io.macula/_realm/_realm/membership/revoked_v1">>

macula_topic:realm_hope(<<"io.macula">>, <<"auth">>, <<"check_health">>, 1).
%% → <<"io.macula/_realm/_realm/auth/check_health_v1">>

%% Org tier — _org fills the app slot
macula_topic:org_fact(<<"io.macula">>, <<"beam-campus">>,
                      <<"licenses">>, <<"issued_batch">>, 1).
%% → <<"io.macula/beam-campus/_org/licenses/issued_batch_v1">>

%% App tier — fully qualified
macula_topic:app_fact(<<"io.macula">>, <<"beam-campus">>, <<"hecate">>,
                      <<"mpong">>, <<"lobby_opened">>, 1).
%% → <<"io.macula/beam-campus/hecate/mpong/lobby_opened_v1">>

macula_topic:app_hope(<<"io.macula">>, <<"beam-campus">>, <<"hecate">>,
                      <<"llm">>, <<"chat_to_model">>, 1).
%% → <<"io.macula/beam-campus/hecate/llm/chat_to_model_v1">>

%% Parse a topic — returns the inferred tier
{ok, #{tier := realm,
       realm := <<"io.macula">>,
       domain := <<"membership">>,
       name := <<"revoked">>,
       version := 1}} =
    macula_topic:parse(<<"io.macula/_realm/_realm/membership/revoked_v1">>).

{ok, #{tier := app,
       realm := <<"io.macula">>,
       org := <<"beam-campus">>,
       app := <<"hecate">>,
       domain := <<"mpong">>,
       name := <<"lobby_opened">>,
       version := 1}} =
    macula_topic:parse(<<"io.macula/beam-campus/hecate/mpong/lobby_opened_v1">>).

%% Validate a topic
ok = macula_topic:validate(<<"io.macula/beam-campus/hecate/mpong/lobby_opened_v1">>).
{error, _} = macula_topic:validate(<<"io.macula.mpong.lobby_opened">>).

Calling from Elixir (no wrapper)

Per the macula-io coding rule "no Elixir wrappers for Erlang", Elixir consumers (e.g. macula-realm) call macula_topic directly via the Erlang interop:

topic = :macula_topic.realm_fact("io.macula", "membership", "revoked", 1)
# => "io.macula/_realm/_realm/membership/revoked_v1"

procedure = :macula_topic.realm_hope("io.macula", "auth", "check_health", 1)
# => "io.macula/_realm/_realm/auth/check_health_v1"

Do NOT write MaculaRealm.Topics or similar wrapper modules. Call the Erlang builder directly. The realm name should come from runtime config, not hardcoded in source.


Validation

macula:publish/4,5, subscribe/4,5, advertise/5, and call/5 do NOT call macula_topic:validate/1 for you. They take Topic/Procedure as an opaque binary and send whatever you pass — an inline, malformed string publishes and subscribes exactly like a canonical one; the two sides just never match if they disagree on the string. Validation is available, not automatic:

ok      = macula_topic:validate(<<"io.macula/beam-campus/hecate/mpong/lobby_opened_v1">>),
{error, _} = macula_topic:validate(<<"io.macula.mpong.lobby_opened">>).

Always building topics via a macula_topic constructor (never inline) means a typo or wrong tier is a wrong value your own tests can catch, rather than two hand-written strings silently drifting apart. If you want it enforced, call validate/1 at your own publish/subscribe call sites, or in your own project's tests/CI — it's not wired into the SDK's send path.

System topics (any leading-underscore prefix) bypass canonical validation entirely: _mesh.node.up, _dist.tunnel.X, _dht.list_gateways etc. are dot-separated infrastructure topics, out of scope for this validator.


Anti-Patterns

WrongWhyCorrect
io.macula.hecate.mpong.lobby_openedDots, no version, no structureio.macula/beam-campus/hecate/mpong/lobby_opened_v1
"#{realm}.membership.revoked" (Elixir interpolation)Inline, dot-form, no tier, no version:macula_topic.realm_fact(realm, "membership", "revoked", 1)
<<Realm/binary, "/foo/bar/baz_v1">> (Erlang interpolation)Inline, no validator, no tiermacula_topic:app_fact(Realm, Org, App, "foo", "bar", 1)
app_fact("membership", "revoked", 1) for a realm-owned eventWrong tier — realm authority owns thisrealm_fact("membership", "revoked", 1)
io.macula/beam-campus/hecate/mpong/game.available_v1"available" is neither past nor present tensePick a tense. Past for fact, present for hope.
io.macula/beam-campus/hecate/mpong/join.{game_id}_v1ID in topicPut game_id in payload
io.macula/_realm/hecate/membership/revoked_v1Mismatched tier sentinels — _realm requires _realm in both publisher slotsEither fully realm (_realm/_realm) or fully app
mpong.games.created_v1CRUD verbmpong/lobby_opened_v1

  • docs/guides/PUBSUB_GUIDE.md — pub/sub usage, payload conventions
  • docs/guides/RPC_GUIDE.md — RPC usage, error handling
  • hecate-social/hecate-corpus/skills/MESH_TOPIC_TIERING.md — Hecate-specific guidance, audit history, anti-patterns from real bugs