Changelog
View SourceAll notable changes to this project will be documented in this file.
The format is based on Keep a Changelog, and this project adheres to Semantic Versioning.
[13.2.2] - 2026-09-29
The frame observer names a pre-v5 liveness probe apart, so a station's idle close can leave probes out (macula-station#25).
Changed
- The frame observer names a pre-v5 liveness probe apart (macula-station#25). A CALL claiming the all-zero liveness
realm and
_macula.pingis reported asliveness_call, in either direction, and a RESULT or ERROR naming one of those requests asliveness_result, so a station's idle close does not count probes as use: before, a dialled connection to a pre-v5 peer stayed "in use" as long as it answered its probe. The typing is for the observer only: no routing, verification or count changes, and a peer that labels real work a probe only makes the connection look idle. The last 4 probe ids each way are kept.macula_frame:claimed_request/1reads a CALL's request_id, realm and procedure without verifying it, for this.
[13.2.1] - 2026-09-29
A handshake v5 fix and a station setting, no wire change. No ordering of two connections keeps a v4 connection a node dialled to a peer it has seen on v5 (macula#53), and a station can set its session proof limits at run time. The fleet's floor release (macula-station 0.7.1) builds on this one.
Fixed
- A v4 handshake that completes to a node this node has seen on v5 is refused (
v5_downgrade_refused), whichever connection finished first (macula#53). Before, the check ran only when a dial chose its version, so two connections to one node could interleave, one completing v5 while the other's v4 CONNECT, chosen from the v4 cache after a fallback, completed, and the v4 connection was kept. Both orderings are closed: a v4 completion registers before it reads the memory (macula_peer_versions:v4_completed/2), and a v5 completion tells every v4 connection this node dialled to that node, still open, to close as a downgrade. A node seen on v5 is always dialled with v5, whatever the v4 cache holds. Only a node's own dials are affected: a station still accepts a v4 CONNECT from a node it saw on v5. Thev5_downgrade_refusedwarning names itscause(unsupported_version,v4_completedorv5_completed_elsewhere), and names the rollback remedy only for the first.
Added
macula_peering:set_session_proof_limits/2(andmacula_session_proof_rate:set_limits/2): replaces a station's session proof limits at run time, once macula has started, before any connection or while connections are live; the next session proof counts against the new limits, andsession_proof_limits/0reports them. Each value must be an integer of at least 1, as the application environment's must at start; an invalid value changes neither limit and returns{error, {invalid_limit, Name, Value}}with the environment key's name. The values are also written to the application environment, so a restart of the peering processes keeps them rather than silently going back to the defaults. For a station whose limits come from its own configuration, which it reads after macula has started (macula-station 0.7.2).
[13.2.0] - 2026-09-29
The first handshake v5 release: a station that runs it makes it its rollback floor, so rolling it back below 13.2.0 is a
stated decision, never a routine pin (plans/DESIGN_NEIGHBOUR_CHANNEL_BINDING.md §4). Observed in a local six-station
test harness (one host, 2026-09-29, 90 s after the rollback): a station rolled back below 13.2.0 stays reachable by the
stations it dials itself, since their v5 peers accept its v4 connections and SWIM liveness, routing-table membership and
DHT pings keep working both ways; only the peers' own dials to it are refused (v5_downgrade_refused, logged with its
node id), and they keep retrying into that refusal, a small steady load, until macula_peering:forget_v5_peer/1 on
those nodes or their restart. A rolled-back station that dials no peers would be cut off from them the same way (by
design; not measured), and a client that saw it on v5 cannot reach it (by design, since a station never dials clients;
not measured) until that client restarts (our own nodes: forget_v5_peer/1). It also brings sealed pubsub groups.
Handshake version 5: a connection is authenticated once, by hybrid proofs bound to its TLS session, instead of by a
composite signature on every control frame (plans/DESIGN_NEIGHBOUR_CHANNEL_BINDING.md). A pq_hybrid station stops
paying an RSA-4096 signature (about 9 ms) per control frame it sends. Stations accept versions 4 and 5; a client
dials 5 and falls back to 4 once, only on unsupported_version, so no peer is refused during a roll.
Sealed pubsub groups: a group's events readable only by its members, with keys the org's distributor hands out
(plans/DESIGN_E2E_SEALED_PUBSUB.md). Built in the Erlang SDK and pinned by vectors for the others. No confidentiality
claim is made for the fleet before the scheme is tested across SDKs and measured (D11). A distributor names its KEM
key only with kem_advertise enabled, so groups wait for the same switch sealed calls do. No wire change: a sealed
publication was already a valid one.
Added
- Handshake v5. The opener and challenge stay version 4; the client picks 4 or 5 in CONNECT and HELLO answers in
the same version. In v5 the CONNECT proof (
MACULA-PQ-CONNECT-PROOF-V2, by the CONNECT key) also covers E, the session's TLS exporter value (EXPORTER-macula-session-v1, context client node_id || station node_id, 32 bytes), and the client's capabilities; HELLO carries the station'ssession_proof(MACULA-PQ-SESSION-PROOF-V1, by its identity key) over E, SHA-384 of the challenge and of CONNECT, both node_ids and the station's capabilities, signed only after every check on CONNECT passes. After HELLO no frame carries a neighbour signature, in either profile; a v5 connection refuses one asmalformed_frame. A composite session proof whose ML-DSA half is valid and whose RSA half is not is refused (session_proof_invalid): the property D17 kept, if ML-DSA-87 were broken. - Falling back, and refusing a downgrade. A station never seen on v5 that refuses a v5 CONNECT with
unsupported_versionis dialled once more, on a new QUIC connection, with a v4 CONNECT, and with v4 for the next 10 minutes; from its second fallback a warning names it, at most once a minute. A station seen on v5 in this run that answers v4 is refused (v5_downgrade_refused), warned per node with its count at most once a minute, untilmacula_peering:forget_v5_peer/1or a restart. That includes a station rolled back below its first v5 release: that release is its rollback floor, because a third-party client that saw it on v5 refuses it until that client restarts. - Liveness on v5. The probe is
liveness_ping/liveness_pong, answered and consumed by the peer's connection, unsigned; the signed_macula.pingCALL probe stays for v4 connections. The DHT's ownping/pongare untouched. - The station's session proof budget. At most
session_proofs_per_node_per_minute(default 30) per client node andsession_proofs_per_second(default 30) in total, macula application environment options read once at start; a value that is not an integer of at least 1 refuses the start. Past either limit CONNECT is refused withsession_proof_rate, on the wire too (only a v5 CONNECT can meet it), logged with the limit. A client refused on the total spends none of its own per-node budget.macula_peering:session_proof_limits/0. - Counters.
macula_peering:handshake_counters/0: connections by version, control frames on v4 connections (the old path, which must read zero fleet-wide before v4 is dropped), v4 fallbacks, refused downgrades, and session proof refusals by reason. macula_quic:export_keying_material/4(the TLS 1.3 exporter) andmacula_quic:tls_posture/0.- Sealed groups.
macula:publish/5andmacula:subscribe/5takegroup => Prefix, a topic prefix whose second segment is its org, withucan_token(the org's grant) anddistributor(a pinned node id). The pool joins the group first, pulling its epoch keys from the org's distributor<org>/group_keys_v1over a call sealed to the distributor's KEM key, and a refusal fails the call closed as{error, {group, Reason}}. A topic outside the prefix, or a prefix with no org segment, is{error, {invalid_option, group}}.- A publish is sealed under the group's current epoch by the link, with the publisher's subkey of the epoch key, a
fresh nonce and the event AAD, and carries
sealedin place ofpayload. - A group subscription's events are opened by a process of their own (
macula_group_opener): an opened event's meta sayssealed => 1andseal_key_id; one that cannot be opened arrives once as{macula_event_unopened, SubRef, Topic, #{publisher, seal_key_id, reason}}, withreasonfrom a closed set, and nothing of its payload. - A node holding a group whose policy is
requiredrefuses clear events under its prefix on every subscription, counted and logged naming the publisher. A clear publish under a held prefix is refused as{error, {confidentiality, {group_held, Prefix}}}.
- A publish is sealed under the group's current epoch by the link, with the publisher's subkey of the epoch key, a
fresh nonce and the event AAD, and carries
macula_group_keys, a sealed group's distributor: epochs of 15 minutes with independent random keys and ids, the next handed out in the current's last third, and every pull admitted only with the org's grant (checked by the advertise policy) and a live realm membership read from the realm's slot, past epochs included, minus an application's removed set. Refusals arehandler_errorwith the reason as detail. Its procedure is advertised withmacula_group_keys:advertise_opts/1: the org's grant and sealed calls only, so a clear pull is answeredsealed_required, and withkem_advertiseoff it refuses to advertise at all.macula_group_keyring, a node's keys for the groups it joined, one per pool: re-pulls at a random instant in every ahead window, retries with backoff, keeps the policy monotonic, bounds unknown-id pulls to three per publisher per epoch, and is read through a handle so that only a pull ever waits. A group has one re-pull pending at a time. Adistributorthat is not a node id, or aucan_tokenthat is not bytes, is{error, {invalid_option, _}}before anything is sent, and a pool whose keyring ends stops with{shutdown, {group_keyring_down, Reason}}.macula_group_epochandmacula_group_eventare its pure parts.macula:call/6takesucan_token, presented to every station call it makes, the resealed one included.macula_subscribertakes an optionalhandle_unopened/3; without it the subscriber logs an unopened event and serves on.subscribe_callback/4's receiver logs and drops one.- Every event's meta carries
published_at, andsealed(0 or 1). - Vectors:
test/vectors/e2e_seal_v1_group_keys.jsonpins the key pull's CALL and RESULT plaintexts and one org-issuedgroup_keysUCAN per profile with its verdicts (test/vectors/E2E_SEAL_V1.md, "Sealed groups").
Changed
- No TLS session resumption, and no 0-RTT, on either end. D16 decided against resumption, but rustls resumes by default and nothing turned it off: a second handshake between the same configurations resumed, and the listener sent two tickets. Every connection is now a full handshake.
- Peering refuses to start on a TLS posture v5 cannot rely on: both ends must offer exactly SecP384r1MLKEM1024
then SecP256r1MLKEM768, neither may do 0-RTT or send tickets, a second handshake must be full, and so must the
dialler's second handshake against a listener that does issue tickets (
macula_tls_posture). This proves the configured posture, not each connection's negotiated group. macula_handshake:answer_challenge/2returns{ok, Connect, Station, ExpectHello}, andread_hello/1isread_hello/2, taking thatExpectHello.macula_dist_tunnelstays on v4: its station answers v5 as an old one.- A sealed EVENT reaches the pool instead of being refused at the link, so a subscription with no group is told
(
reason => no_group) rather than left with nothing.
Fixed
- A pattern subscription's flushed events carry their topic (#49). An event the ordering buffered and released
on
order_timeout_mswas sent with the subscription's pattern; each event now keeps its topic through the ordering.
[13.1.0] - 2026-09-28
A caller can learn whether the exchange behind its result was sealed, and to which key. Opt-in: no 13.0 caller's return changes. No wire change.
Added
- A call's seal report.
macula:call/6andmacula:call_station/8takereport => true; a result then comes back as{ok, Result, #{sealed := 0 | 1, provider := Target, seal_key_id => KeyId}}.sealedis 1 when the request that produced the result was sealed and its answer opened under the same key, whose id it names; 0, with no key, for a clear call. After asealed_refusedand a reseal it names the reseal's key. An error carries no report. Areportthat is not a boolean is{error, {invalid_option, report}}before anything is sent.call_station/8honours the option because the pool's own direct dial calls through it. Below the facade:macula_station_link:call/9andmacula_client:call_station/12carry the flag,macula_direct_dial:report_option/1checks it, and the report type ismacula_station_link:report(). Seeplans/DESIGN_E2E_SEAL_REPORT.md. - A stream's seal report.
macula:stream_report/1(andmacula_stream:report/1) answers the same map for a stream the caller opened. It settles on the provider's first STREAM_DATA or STREAM_REPLY opened under the stream's key, or on a clear stream its first STREAM_DATA, STREAM_REPLY or STREAM_END; before that, and on a stream that ended first, an error included, it is{error, not_settled}. A sealed stream's STREAM_END travels clear and settles nothing. A served stream answers{error, not_a_caller}. The report is fixed when it settles. A provider that refuses a stream's key (sealed_refused) and then, before the reopen lands, answers under that same key ends the session (malformed_frame), told to the provider on the reopen's session, and the report keeps naming the key the answer opened under.reportin a stream's options (call_stream/5,call_stream_station/7) is{error, {invalid_option, report}}whatever its value, as the C ABI refuses it, rather than accepted and ignored. macula_record:realm_member_endorsement_key/2: the DHT slot of a realm member's endorsement and of the realm's tombstone of it, whichmacula_hyparview_endorsement:slot_endorsement/3,4reads (macula-realm#31).
Both reports state that sealing ran on the exchange, nothing more (D11).
[13.0.1] - 2026-09-27
Types, the dialyzer check that should have caught them, and one option that was accepted and did nothing. No wire change.
Fixed
macula:call/6andmacula:call_stream/5refuseconfidential => off. A call they resolve is sealed to the advertisement it resolves (a lookup never downgrades a call, design §8.1), sooffwas accepted and ignored: the call went sealed anyway. It is now{error, {confidentiality, off_needs_explicit_target}}before anything is looked up, as macula-go refuses it; a clear call iscall_station/8's, to a target the application names. Any value other thanpreferredorrequiredis{error, {invalid_option, confidential}}. Found by Venus's interop run.call/6's spec now namesconfidential, which it did not.macula_client:seed()namesexpected_node_id. A seed map may pin the node_id its station must prove, and connect/2 reads that pin and documents it, but the type left the key out. Every consumer that dials by pin (every mcl service) broke the contract ofmacula:connect/2,macula_client:connect/2andmacula:call_station/7,8in its own dialyzer, which then marked everything after the call unreachable (33 warnings in mcl-echo, found by Terra).macula_client:opts()namesorder_timeout_msandorder_max_buffer. The pool reads both for itsorderedsubscriptions; a consumer that set them broke the contract of connect/2.macula_frame:request_spec()carriessealed. A sealed request's spec has asealedfield and nopayload; the type required a payload and named no seal.- A pending call keeps its seal beside its request, not inside it. The link held a sealed call's keys in the
verified request's map, which
verify_reply/3andverify_relay_error/4type as a request and nothing more. The pending entry is now{From, TRef, Request, Seal}, and format_status/1 still showssealedin place of the keys.
Changed
- dialyzer reports a call that breaks a contract and a function that cannot return.
no_fail_callandno_returnwere turned off in rebar.config from the first commit, so none of the above could fail this repository's own check. With them on, the three types above and one OTP spec gap were all it found. The gap is ssl:connect/3, whose spec lists nocb_infocarrier process;macula_dist_tunnel:dialled/2suppresses that warning by name. consumer_contracts/macula_consumer_contracts.erlmakes a consumer's calls for dialyzer to check. It connects and calls through a pinned seed, as the services do. CI's dialyzer runsrebar3 as consumer_contracts dialyzer, whose profile adds it as an extra source directory; nothing runs it and the hex package does not carry it. Seen red against 13.0.0'sseed(),opts()andcall/6spec.
[13.0.0] - 2026-09-26
End-to-end payload confidentiality for calls and streams (plans/DESIGN_E2E_PAYLOAD_CONFIDENTIALITY.md, packages 2 to 4, with Amendment A1): a call or a stream to a provider that names a KEM key is sealed by the caller to that key, and the provider seals what it answers. E2E seal scheme 1: ML-KEM-1024, plus P-384 in pq_hybrid, HKDF-SHA-384, AES-256-GCM, pinned by test/vectors/e2e_seal_v1.json. What a station on the path still sees is the design's §9 list. No confidentiality claim is made before the scheme is tested across SDKs and measured (D11).
Changed (breaking)
macula:call_station/7,8decides to seal from signed state, or refuses the call. It seals to the KEM key of the verified advertisement of THAT target passed asadvertisement, or sends in the clear when that advertisement names no key.confidential => offsends in the clear, as the application's own decision.confidential => requiredwithout an advertisement resolves the target's advertisement and fails closed. Anything else,call_station/7included, is{error, {confidentiality, no_signed_state}}. An advertisement of another node or procedure is refused by name (not_the_target,not_the_realm,not_the_procedure). A lookup can deny a call, but never downgrade it (design §8.1). Call sites to update:- mcl-om
src/mcl_om_capabilities.erl(dial_provider), which now passes its verified advertisement (mcl_om 0.33.0); - mcl-echo
apps/mcl_echo/src/mcl_echo_call.erl(its recording dial I/O forwards direct dial's options, which now carry the advertisement, so only its pins change); - macula-station's test suites and its test-cluster harness, which pass
confidential => off.
- mcl-om
macula_client:call_station/7,8,9,10are removed. They sent in the clear with no signed state, walking around the rule above.macula_client:call_station/11takes its seal explicitly (clearor{sealed_to, Key}), andmacula:call_station/7,8decides it. No consumer in macula-station, macula-realm, mcl-om or mcl-echo called them.macula_station_link:call/7's gen_server message carries its seal (an eighth element). Only code that sends the raw message, rather than callingcall/7,8, is affected.- A pre-signed advertisement that names a KEM key is refused at registration
(
{confidentiality, presigned_keyed_advertisement}). A key is named through a spec, whosekeyed_sincethe pool keeps, so a link respawn never reopens a provider's clear-call window. - A direct-dial candidate carries its verified advertisement (
advertisement), and so does the head start the pool remembers. macula:call_stream_station/7decides to seal from signed state, or refuses the stream, on exactlycall_station/8's terms:advertisement,confidential => off, orconfidential => required, and anything else is{error, {confidentiality, no_signed_state}}before anything is sent.macula:call_stream/5(direct dial) hands each candidate's verified advertisement to it.macula_client:call_stream_station/7is removed.macula_client:call_stream_station/8takes its seal explicitly, ascall_station/11does.macula_station_link:call_stream/6requiressealin its options (clearor{sealed_to, KemKey}): an open that names none raisesfunction_clausein the caller and opens nothing.- A link-carried stream ignores the in-process pair's deliveries (
peer_chunk,peer_end,peer_error,peer_reply), which no verification or seal stands behind. It takes its peer's frames only as verified frames.
Added
A provider's advertisement names its KEM key, the
kem_keypair 12.11.0 admits, once the node is switched on with thekem_advertiseapplication setting (defaultdisabled; enable it only when every station runs a release on macula 12.11 or later, and every caller runs 13).macula:advertise/5andmacula_response:advertise_direct/6,7takeconfidential(preferred, the default;required;off).requiredalso refuses clear calls, and is refused at advertise time askem_advertise_disabledwhile the node is switched off. The key is read from the keyring at every signing, so renewals carry a rotated key. The switch is read when a procedure is advertised and renewed, not a live off switch: turning it off stops new keyed specs, but a stored spec keeps naming the key until its chain lapses.macula_kem_keyring: one KEM key per node identity per VM, in memory only, rotated every 24 hours. A replaced key still opens calls for 30 minutes, then is deleted. A stolen key opens at most about 24.5 hours of calls. Precondition: one node identity runs in one VM.A provider opens a sealed CALL and seals every answer to it: a RESULT, a handler's error, an unknown procedure, an unauthorized request, a crash, all sealed under the call's reply key with a fresh nonce. A reply's code and detail travel sealed. A call that does not open is refused in the clear as
sealed_refused, naming the key the provider holds now. A procedure whose spec saysrequired, or one that has named its key past the window its last keyless advertisement lived in, refuses a clear call assealed_required.A caller seals and opens.
macula:call/5,6seals to the key the advertisement it resolved names (confidential => requiredrefuses keyless providers). Asealed_refusedis followed by ONE fresh lookup: the call is sealed again only when the provider's advertisement names exactly the key it named, and otherwise fails as{confidentiality, {key_mismatch, Named, Found}}orno_kem_key. It never falls back to the clear. Against a sealed request, a clear answer is accepted only as a relay error or an admission refusal from the closed set (expired,not_yet_valid,request_id_reused,request_copy,reply_not_kept,caller_quota,share_full,admission_full,too_many_sessions,unavailable). A sealed reply that does not open fails the call as{confidentiality, reply_not_opened}. A provider that answers it holds no key gets the same single re-resolve.What stays visible. A request's
tokenandproofs(the caller's UCAN and its delegation chain) travel in the clear besidesealed: scheme 1 seals the payload only. Sizes, timing and routing fields stay visible too (design §9).A sealed call's secrets stay out of logs.
macula_node_keys:redacted/1redacts a call's or stream's keys and a KEM private key's halves by name. A pending sealed call shows assealedin the link's status, and a handler crash on a sealed call is logged by its reason's name and frames without their arguments.macula_sealed_call,macula_seal:generate_key/1,public_key/1,carried_key_size/1,macula_frame'spayload_plain/1,plain_payload/1,error_plain/1,plain_error/1, and the builders'sealedfield.The vectors pin a sealed ERROR (
error_reply: plaintextcbor([code, detail])).Sealed streams (design §5.2). A STREAM_OPEN sealed to the provider's KEM key agrees two stream keys, and every later STREAM_DATA, STREAM_REPLY and STREAM_ERROR is sealed under the key for its direction:
- a caller's frames use a nonce derived from its signed seq; a provider's use a random nonce each frame carries;
- the AAD names the frame type, the request, the seq and the direction;
- STREAM_END carries nothing to seal.
The receiving stream opens each frame before anything of it is kept, so readers get plaintext. A sealed frame that does not open, a clear frame on a sealed stream (a downgrade), or a clear error outside the closed set ends the session
malformed_frame. Once decapsulated, the stream keys no longer depend on the keyring, so a stream outlives a KEM key rotation.A provider opens a sealed STREAM_OPEN and serves it sealed. Admission refusals decided before anything is opened go in the clear from the closed set: session caps (
too_many_sessions,unavailable) andsealed_refusednaming the key it holds now. The session caps are read before decapsulation, so a caller at its cap costs no ML-KEM decap. Anything decided after the open is about the procedure and goes sealed as the provider's first frame:unauthorized,not_found,mode_mismatch, and a stream already carrying a session. A clear open to a procedure that takes only sealed ones is refusedsealed_required.A stream refused
sealed_refusedbefore it has sent anything reseals once. Its reseal resolves the named key, bound as a call's is, and its link reopens it under a new request id and a new encapsulation, keeping the stream's pid. Both run outside the stream, so it keeps answering; what its owner sends meanwhile waits and goes out under the new open. A stream refused after its first send is not resealed: it ends{sealed_refused, KeyId}.macula_bridgethen resets the local TCP connection, and the client reconnects, which resolves afresh.A provider stream seals at most
max_sealed_framesframes under its random nonces (default 2^32, the GCM bound), then refuses{error, sealed_frames_exhausted}.macula_sealed_call:clear_refusal/1andrefused_key/1(the closed set and a refusal's named key, shared by calls and streams),macula_station_link:reopen_stream/6,macula_stream_sessions:has_room/1.
Not sealed in 13.0.0
- D27 content transfers stay in the clear. A content fetch trusts no realm by design (content verifies itself by
its content id), so it holds no signed source for the sharer's KEM key. It opens its streams with
confidential => off, explicitly, and a relaying station reads the bytes it relays, as before. The content itself is public: anyone holding its MCID may fetch it. Sealing the transfer needs the sharer's key in its signed content announcement, which is a package of its own (design §5.3, §9).
Test vectors
test/vectors/ucan_v1.jsonandUCAN_V1.md, the UCAN contract every SDK implements (D7), test-only: tokensmacula_ucanminted in both profiles, each with its policy, context and verdict, covering every refusal, the order the checks run in, and which capability a chain carries; proof ids, the keys' did:keys, key ids and node_ids, and the narrowing matrix.scripts/generate-ucan-vectors.shwrites it frommacula_ucan, andmacula_ucan_vectors_testsre-derives every verdict from it on each run.
[12.12.0] - 2026-09-26
Added
macula_bridge: an unmodified TCP client reaches an unmodified TCP service across the mesh, one bidi stream per connection, under the procedure's auth policy (serve/5,serve_with/3,listen/4,listen_with/2,handler/2,local_port/1,stop/1).serve/5refuses to run without an explicitauth. Credit is granted by the receiver and counts what a chunk costs the receiving stream, capped on the serving end at one served session's share of its caller's budget. A half-close, a reset, an idle connection and a write the peer stops reading are each carried to the other side, or end the connection.macula_stream_sessions:session_share/0: one served session's share of a caller's budget (the caller budget divided by the per-caller session cap, 1 MiB by default).
Fixed
- A QUIC close on a dedicated stream ends its sessions. A
stream_closedorpeer_send_shutdownunder an open session fell to a catch-all, and the session waited until its TTL. The owner now reads{error, {<<"disconnected">>, _}}, as it does for a failed write.
[12.11.1] - 2026-09-26
Security
- A handler reads the verified caller, never one the payload names. A CALL handler's args carried the
wire-authenticated caller under the atom key
caller, but a payload's owncallerfield stayed beside it as the text key{text, <<"caller">>}.macula:field(caller, Args)(andfield/3, by atom or binary name) andmacula_record:payload_field(Args, <<"caller">>)look up the text key first, so a handler reading its caller either way got whatever the caller wrote, and any identified node could present itself as another. Onlymaps:get(caller, Args)was right. The payload'scalleris now removed before the verified one is set, so every way of readingcallergives the verified identity. A served stream's handler got no verified caller at all, so acallerin its payload was the only one it could read; it now gets the verified one the same way (below). Found by Fable's review of the bridge package. An audit of the macula-io, macula-services, macula-internal and reckon-db-org repositories found no handler that readcallertext-first: the realm (member checks read the atom; admin procedures authorise onadmin_token), mcl-om (mcl_om_wire:field/3reads the atom first) and the station (reads none). Upgrade anyway: the documented reader was the vulnerable one.
Changed
- A served stream's handler gets its verified caller in its args (
caller), as a CALL handler does, andmacula_stream:info/1names the node whose signed STREAM_OPEN opened the stream (caller): the remote on a served stream, this node on one it opened,undefinedin-process. ⚠ A stream handler that matches its args map exactly must allow the extra key.
[12.11.0] - 2026-09-26
Added
- A procedure advertisement may carry its provider's KEM key (E2E payload confidentiality, Amendment A1).
macula_record:procedure_advertisement/5takes akem_keyoption (the key as carried: 1568 bytes of ML-KEM-1024, or 1665 with a P-384 point) and writes it with itskem_key_id(the first 8 bytes of SHA-384 over it).read_procedure_advertisement/1returns both when present. The two travel only as a pair: a lone field, a key of another length or an id that is not its key's is refused asmalformedat sign, at verify, and by every station's STORE and ADVERTISE admission. ⚠ A node on 12.10 or earlier refuses a keyed advertisement, so stations move to this release first, then callers, and only then do providers name a key (13.0.0, behindkem_advertise). No advertisement carries a key in this release, since nothing builds one yet.
[12.10.0] - 2026-09-26
Changed
- A procedure delegation lives at most 30 minutes (D32 step 3, #38).
macula_recordrefuses a longer one at signing and at verifying (lifetime_too_long), so neither a realm by mistake nor a stolen org key can mint around the revocation bound. ⚠ A realm still issuing 6-hour delegations has every provider refused under this release. The io.macula realm has issued 30-minute ones since its 17dc65b rolled at 2026-09-26T07:07:20Z, so the last 6-hour delegation it issued lapsed by 13:12:20Z, clock tolerance included. A 0x16 tombstone now lives 30 minutes plus twice the clock tolerance, which outlives every delegation this release accepts. The org directory keeps its 6 hours.
[12.9.1] - 2026-09-26
Fixed
- A busy link no longer stalls the pool (#44). The pool asked every link
is_connected(andpeer_node_id) from inside its ownhandle_call, on every publish and everylinks/1andstatus/1; a link busy signing or verifying frames answered nothing meanwhile, so every pool call waited behind it (live: beam02 mcl-mpong's 5 ssign_node_recordtimed out 99 times). A link now tells the pool its station at handshake ({macula_link_connected, Link, StationNodeId}), and the pool answers publish target selection,links/1,status/1, link reuse by node id, the resolved-candidate head start and the discovered-link watchdog from its own state, dropped on the link's disconnect notice or DOWN. A worker waiting on a link (await_connected/2) still asks the link itself, outside the pool. - A linked-station call no longer probes links (#44).
put,find_record,find_records_by_typeandlistgo throughcall_linked_station/5, whose worker asked each linkis_connectedunguarded: a busy first link crashed the worker after 1 s and its caller got no answer until its own timeout. The worker now takes the links the pool holds connected and calls the first, so a busy link answers that caller with its own timeout. - A busy link no longer takes the pool down on a subscribe (#44). The pool
subscribes every link inline, and a link that did not answer within 5 s
exited the pool with every subscription, advertisement and pending call. A
link that does not answer, or is gone, is now skipped and logged at warning
(
_macula.client.link_subscribe_skipped); its next respawn replays the subscription. The pool still waits on that link for up to 5 s: it no longer probes links, but it still calls them to subscribe and advertise.
[12.9.0] - 2026-09-26
Every verifier accepts a sealed payload, and every endpoint refuses one by name
while it cannot open it yet. This is the first step of end-to-end payload
confidentiality (plans/DESIGN_E2E_PAYLOAD_CONFIDENTIALITY.md §13 #6): every
station must run it before any SDK sends a sealed payload. Nothing sends one yet.
Wire-compatible with 12.0 to 12.8 for every frame they build; a sealed frame
reaches only this release and later.
Added
A signed tbs may carry
sealedin place of its payload field.- It replaces
payloadin a request, a reply and a publication, andbody,payload, orcodeandmessagein a stream frame (a STREAM_DATA keeps itsencoding). macula_frame:verify_request/2,verify_reply/3,verify_provider_stream/3,verify_caller_stream/3andverify_publication/3accept it and return it as#{sealed := #{scheme, key_id, ct, kem_ct | nonce}}.- A station verifies and routes a sealed frame as any other, and never charges it.
- The shape is held to its frame:
- scheme 1 and an 8-byte key id, always;
- a request's carries a
kem_ctof 1568 or 1665 bytes and no nonce; - a reply's, an event's and a provider stream frame's carry a 12-byte nonce
and no
kem_ct; - a caller stream frame's carries neither: its nonce is its seq.
macula_frame:sealed()names the shape, andverified_request()andverified_publication()carrypayloadorsealed.
- A tbs with both
sealedand the payload it replaces, or with neither, is refusedmalformed_frame, as is a STREAM_END withsealed.
- It replaces
An endpoint refuses what it cannot open yet, by name, and crashes nothing.
- A sealed CALL is answered with a clear provider ERROR
sealed_refused, and its handler never runs. - A sealed STREAM_OPEN is refused
sealed_refusedon its own stream, right after admission and before any policy, as a sealed CALL is. - A sealed RESULT answers its caller
{error, {call_error, <<"sealed_refused">>, undefined}}. - A sealed stream frame ends its session
sealed_refused. - A sealed EVENT is not delivered, and is counted and logged at most once a window.
- Before this, a peer that sent a sealed-shaped frame to a 12.8 endpoint was
refused as malformed. From 12.9 the frame verifies, so without these
refusals a pattern match on
payloadwould have crashed the link or the stream.
- A sealed CALL is answered with a clear provider ERROR
macula_seal, the end-to-end payload sealing primitives, scheme 1 (plans/DESIGN_E2E_PAYLOAD_CONFIDENTIALITY.md§13 #1). ML-KEM-1024, plus an ephemeral P-384 ECDH inpq_hybrid, combined with HKDF-SHA-384; AES-256-GCM seals. It covers the call, reply, stream and event keys, their AAD and nonces, and the refusal of a malformedkem_ctor P-384 point. Nothing sends a sealed payload yet: frames and policy come in later packages.test/vectors/e2e_seal_v1.jsonandE2E_SEAL_V1.md, the byte-exact contract every SDK implements.scripts/e2e_seal_vectors(Rust,macula-mlkemwith fixed seeds, RustCryptop384,hkdf,aes-gcm) generates them, andmacula_sealon OTPcryptoreproduces them. Arefusalsvector pins the one ECDH input every recipient MUST refuse: an ephemeral point that makes the P-384 output 48 zero bytes, which Go'scrypto/ecdhand OTP'scryptoboth return without an error. Two independent implementations agree on every intermediate value, in both profiles. A vector carries each ML-KEM key as its 64-byte seed (the form Go loads) and as the expanded key OTP loads.
[12.8.0] - 2026-09-26
Wire-compatible with 12.0 to 12.7. For a consumer that addresses one station through one pool link, as macula-realm's peer overlay does.
Added
macula:ensure_station_link/4, a pool link to one pinned station. It is a live link the pool holds, else one it dials, answered once its handshake completes within the timeout, or{error, not_connected}. The pool owns, respawns and ends the link. With it, the facade forms of the overlay calls a consumer drives the link with:macula:overlay_subscribe/3,overlay_unsubscribe/2, andsend_overlay_frame/2,3.
Fixed
- 12.6.0 removed a function a consumer called. It dropped
macula_client:ensure_station_link/4as dead code with the station-served content path, but macula-realm's overlay (PeerResolver.dial/4) called it, so no realm builds on 12.6.0 or later. The check before the removal grepped the Erlang consumers and missed the Elixir ones. The function is back asmacula:ensure_station_link/4on the public facade, not as the internal. A consumer should call the facade; the internal module stays internal. Every repository, in Erlang, Elixir and Gleam, was then grepped for every function 12.6.0 and 12.7.0 removed:- macula-realm called this one.
- macula-e2e still calls the station-served content functions 12.6.0 removed
(
put_content/2,get_content/2,get_content_station/5,resolve_content_provider/2,macula_download:start_link_direct/4,5). It is rewritten ontoshare_content/get_contentwhen it is repinned. - No other repository calls a removed function.
- An overlay frame sent on a link still handshaking was answered
okand then dropped by the peering connection as an unexpected event.send_overlay_frame/2,3now answers{error, not_connected}until the handshake completes, as documented.
[12.7.0] - 2026-09-26
A provider picks the stations it serves through and keeps its authorization fresh, the QUIC handshake agrees on AES-256-GCM only, a stream to an absent provider fails at once, and a connection can report what each frame costs. Wire-compatible with 12.0 to 12.6 at the frame level.
A peer offering only AES-128-GCM or ChaCha20 for the TLS handshake is now refused, in both roles. Every released Macula SDK and station offers AES-256-GCM among its suites, so none is affected.
Added
stations => [StationNodeId]onmacula:advertise/5,advertise_stream/6, and the supervisedmacula_response,macula_streamerandmacula_uploadadvertise/6andadvertise_direct/7. The procedure registers on the links to those stations only, each named by the node id its link pins. A link respawned later registers it again only when its station is one of them, and an unadvertise withdraws it there.- A station the pool holds no link to is refused as
{error, {station_not_linked, StationNodeId}}, an empty list as{error, {stations, empty}}, and anything but a list of 32-byte node ids as{error, {stations, malformed}}. Nothing is registered or kept. - With
stations, the direct-dial record (advertise_direct/7,macula_direct_dial:publish_advertisement/5) names the first of those stations the pool is connected to, where the procedure is registered. - Without it, every link registers the procedure, as before, and an
advertise made while no link is up answers
{error, no_healthy_station}and is kept for the links that come back. - An
advertiseoverride option replaces the pool fan-out, so it decides where the procedure registers;stationsthen only chooses the station the direct-dial record names.
- A station the pool holds no link to is refused as
frame_observeron a peering connection (macula_peering:connect/1,accept/2), opt-in:fun((queued | out | in, FrameType, Bytes, Us) -> _), called in the connection for every application frame.- A frame written is seen as
queued, with the microseconds it waited in the connection's mailbox sincemacula_peering:send_frame/2, then asout, with the microseconds encoding it and the write that carried it took. A write carries up to 64 coalesced frames, and each of them reports it. - A frame read is seen as
in, with the microseconds decoding and verifying it took. Bytesis the frame's size on the wire, its length prefix included.- An observer that raises is logged and dropped; the connection serves on.
- A frame written is seen as
The pool renews an advertised chain before it runs out (D32, #38).
macula:advertise/5andmacula:advertise_stream/6register how to resolve the chain again (macula:renew_authorization/4, an MFA, never a closure), and the pool asks for a fresh chain at a third of the spec's remaining life, in a worker, then registers it on the registration's stations that have a link at that moment; a station that comes back gets it when its link respawns. A failure or a crash is retried on a backoff (renew_backoff_ms, default 5 s) that never passes the chain'snot_after; past it the pool logs at error level, naming the procedure and the last reason, and asks again everyrenew_recheck_ms(default 5 min), so a re-grant revives the provider without a restart. An unadvertise or another advertise drops a renewal in flight. This is harmless with today's 6-hour delegations, and it has to be in every provider before the realm shortens them to 30 minutes, which bounds how long a revoked provider stays callable.macula_client:advertise/8andmacula_client:advertise_stream/9take the renewal MFA.- ⚠ The direct-dial DHT record (
macula_response:advertise_direct/7) is not renewed by the pool: an app that republishes it must resolveauthorizationagain each time (macula:provider_authorization/3).mcl_omdoes.
Changed
- The TLS 1.3 handshake agrees on
TLS13_AES_256_GCM_SHA384alone (macula-pqc 0.3, macula#39). QUIC still protects its Initial packets with AES-128-GCM, as RFC 9001 fixes for QUIC version 1: the NIF hands that suite to Quinn apart from the handshake's list (macula_pqc::quic_initial_suite(), which macula-pqc's own tests pin to AES-128-GCM). macula_peering:send_frame/2stamps each frame with the time it was queued. A process standing in for a connection in a test now receives{send_frame, QueuedAt, Frame}.
Fixed
- A stream to a provider the station cannot reach ends at once (#42).
The station's signed relay STREAMERROR (
unknown_next_peer) was refused by the caller's stream asmalformed_frameand dropped, sorecvwaited out its deadline: 20 s against a lab station, where a CALL to the same absent target answered in 15 ms. The caller's stream now verifies it against its own STREAM_OPEN and the station its link is connected to, and ends with `{error, {<<"unknown_next_peer">>, }}: 16 ms measured. For content, a sharer gone offline no longer costs each fetch a fullchunk_timeout_ms` once the station has noticed it is gone. - A call on an ended stream answers
{error, closed}(#41).send,recv,abortandawait_replyexited the caller withnoproc;closeandclose_sendanswerok. macula_streamer:advertise/6passesstationsto the stream advertisement, so a streaming provider registers where its direct-dial record says.
[12.6.0] - 2026-09-25
Content is served by the node that shares it (D27). A station keeps no content: macula-station stopped storing it at D27 (647dfba), so content put or fetched through a station has not worked since. Wire-compatible with 12.0 to 12.5 at the frame level. A 12.5 verifier accepts a 12.6 content announcement, but only 12.6 can fetch from one; a 12.6 fetcher refuses an older announcement, which names no realm, station or procedure.
Needs macula-station 0.6.2 or later on the station a sharer is linked to (the
content procedure's advertisement is renewed on the same connection), and 0.6.4
or later for the ~<node id>/content_v1 form a node without an org serves on.
Added
macula:share_content/3,4,unshare_content/3,get_content/3,4. A node shares bytes in a realm and gets the root MCID back. It keeps them in its pool's sharer, serves them on aserver_streamprocedure of its own (~<node id>/content_v1, or<org>/content_v1_<node id>with#{org => Org}), and announces them in the DHT, naming the realm, the station it is reachable through and that procedure. The announcement is renewed while the content is shared, made again when the node moves station, and withdrawn with a tombstone on unshare.- Announcing runs in the background, so sharing and serving never wait on the DHT. An announcement that did not land, or a renewal that fell while no station was connected, is made at the next station check (30 s).
- A realm serves only what is shared in it. The same bytes shared in two realms are announced in both, and unsharing in one leaves the other.
get_contentfinds the announcements and tries each sharer through the station it named, in a random order. It accepts only a procedure bound to the announcer and verifies every block, manifest and chunk against the MCID. Each chunk must be the size its manifest declares, so a fetch never receives more than the manifest's size, whichmax_bytesbounds, and a raw root larger than a chunk is refused. The fetch runs in a worker that ends with the caller and leaves nothing in its mailbox; a chunk stream that fails moves the fetch to the next sharer and never takes the caller down. It answers{ok, Bytes},{error, not_shared}or{error, {unavailable, [{Sharer, Reason}]}}, naming each sharer that failed. Bounds:max_bytes(256 MiB),max_chunks(16,384),parallel(4),chunk_timeout_ms(15 s).- Modules
macula_content_store,macula_content_serve,macula_content_sharer(one per pool, undermacula_content_sharer_sup) andmacula_content_fetch.
- The content announcement record (0x11) carries
realm_id,serving_stationandprocedure, and a verifier refuses one without them.
Changed
macula_feederandmacula_downloadshare from and fetch from the node.start_link/4,5,6keep their arguments. The start options are nowshare,unshare,share_optsandfact_publish(feeder) andfetch,fetch_optsandfact_publish(download). A feeder cancelled while its share is in flight unshares it.macula_record:content_announcement/3takes the announcement's opts,realm_id,serving_stationandprocedurerequired, in place of an endpoint;content_announcement/4is gone.
Removed
- Removed: put_content/2, get_content/2, *_station forms and the station-store
calls; non-functional since station D27 (647dfba); use
share_content/get_content/4.
macula:put_content/2,get_content/2,put_content_station/4,5,get_content_station/4,5,find_content_providers/2.macula_direct_dial:put_content/4,5,get_content/3,4,fetch_content/4,5,resolve_content_provider/2,3.macula_feeder:start_link_direct/5,6,7,macula_download:start_link_direct/4,5,6.macula_content_transferand its registry.macula_station_link:open_content_stream/1,call_on_stream/6,close_content_stream/2,abort_content_stream/4.macula_client:pick_connected_link/1,ensure_station_link/4.
[12.5.1] - 2026-09-25
Wire-compatible with 12.0 to 12.5 in both directions. Upgrade every provider: on 12.5.0 and earlier a provider's request admission never lets an entry go.
Fixed
- A provider's request admission lets its entries go at expiry (#37). An
entry is kept until its request's deadline plus 5 minutes, but it was removed
only by
macula_request_admission:sweep/2, which nothing called, so the set only grew. A station's liveness probe (_macula.ping, every 30 s on every link) took an entry each time, filling that station's caller quota on the provider in about two hours: from then on the provider refused the calls the station relayed ascaller_quota, and in time would have refused every caller asadmission_full. Seen on mcl-echo: 1313 entries of 53 callers, four stations at 256.- The expired entries leave before each request is judged, so a bound counts only live ones. They are held in order of expiry, so this costs only the entries removed.
- The pool sweeps its admission every
admission_sweep_ms(default 30 s), for the callers that never ask again. - The liveness probe of a link's own station is answered
unknown_next_peeras before, without taking an entry. Only that one: a_macula.pingfrom any other caller, or targeted at another node, is judged like any request.
[12.5.0] - 2026-09-25
Wire-compatible with 12.0 to 12.4 in both directions at the frame level. The new
~ form is refused as no_authorization by a caller on 12.4 or earlier and by
every station released so far (up to macula-station 0.6.2): a station admits it
from macula-station 0.6.4, which is not released yet. An agent on another SDK
(macula-mcp over the TypeScript SDK, macula-go) serves this way once that SDK
carries the same rule.
Added
- A node can serve under its own namespace. A procedure named
~<node_id as 64 lowercase hex>/<name>is authorized by the advertisement's own signature: it carries no org directory and no delegation, and a verifier accepts it only when the advertisement's signer is the node the namespace names (D25 item 6, revised 2026-09-24, Raf's decision). An org-less node, such as an agent or a CLI, can now offer request and reply, with run-once admission and a reply bound to the request, without a human admitting it to an org.macula_record:own_namespace/1is the one rule. Another node's namespace isnot_own_namespace, a namespace that is not 64 lowercase hex ismalformed, and an attached authorization isauthorization_not_allowed.macula_record:verify_authorization/3accepts the form with no realm key, so a caller that pins no realm can still call it.macula:advertise/5andadvertise_stream/6on a~<own node_id>/<name>procedure resolve no chain and hand the pool a spec with no authorization and no bound.macula_clientandmacula_station_linkaccept that spec for a~procedure only (macula_station_link:own_namespace_spec()).macula:provider_authorization/3,4answers{ok, undefined}for the node's own namespace, whichpublish_advertisement/5reads as no authorization, and{error, {provider_authorization, not_own_namespace}}for another node's.test/fixtures/own_namespace/holds signed advertisements for both profiles and the verdicts every SDK must reach on them, shared with macula-go.
plans/PLAN_POST_QUANTUM_SECURITY_DECISIONS.mdD25 carries the amendment. It also merges the "Who may provide a procedure" and "Procedures without an org namespace" answers, which each appeared twice.
[12.4.0] - 2026-09-24
Wire-compatible with 12.0 to 12.3 in both directions.
Fixed
- Station discovery works on 12 and finds mcl-stations. It could not work
on 12 at all, measured against nuremberg: a discovery-enabled pool stayed on
its one bootstrap link. It called
hecate_stations.list_stations, retired with hecate-*. It looked for the directory's realm in aprocedure_urifield that 12 advertisements do not carry. And it built unpinnedquic://hostnameseeds, which a 12 link refuses ({seeds, expected_node_id_required}). Nowstation_discoverytakesprocedure(defaultmcl-stations/list_stations, refused at connect without an org namespace), reads the realm from the advertisement whoseprocedurematches, and dials every listed station pinned to itsnode_id: the hostname when the row has one, else its first advertised address. The directory's advertisement is trusted through the realm key the pool pins, so enabled discovery needsrealm_trustnaming the directory's realm; a pool without one is refused at connect ({station_discovery, realm_trust_required}), and a discovery run that finds nothing logs why. (#31) - A plain
macula:advertise/5provider stays routable. A station drops an advertisement when it expires (at most 5 minutes), and the SDK sent one only on advertise and on connect. Each link now renews a spec's advertisement at half its remaining life, signed in the pool and naming the same station, until the spec'snot_after. A signing refused for a reason that can pass (the pool busy) is tried again a second later. A spec may carryttl_ms, from one second up to the advertisement type's maximum (macula_record:procedure_advertisement_max_lifetime_ms/0, new). This needs macula-station 0.6.2 (macula-station#7) on the station, since an older one drops a renewal on the same connection. (#32) - A draining connection keeps draining when the peer's FIN arrives. When this
side closed first, the peer's control-stream FIN stopped the connection at once
and cut this side's in-flight dedicated streams. The drain now ends on
dedicated_streams_idleor its timeout, whoever closed first. A control stream closed with an error still ends it at once. (#36) - The crypto profile docs no longer give a profile a key exchange. The
orphaned
key_exchange_groupcommentary ondefinition()is gone.
Added
- Request-admission refusals name who was refused. The once-a-minute warning
now lists the callers (node id prefix) and procedures refused in the window,
most refused first, and
macula_request_admission:refusal_sources/1returns the latest list by kind.macula_refusal_report:refused/4counts a refusal with its source, bounded to 64 sources per window with the rest asother. (#34) macula_client:station_seeds/1andfind_list_stations_realm/2are exported for tests of the directory's reply shape.
[12.3.0] - 2026-09-24
Wire-compatible with 12.0, 12.1 and 12.2 in both directions: the ADVERTISE frame and the advertisement record are unchanged.
Fixed
- The advertisement a provider's ADVERTISE frame carries names the station it
was sent to as
serving_station. The facade signed one advertisement naming the provider's own node id and every link sent it. Now the pool signs one per link, naming the station that link is connected to, bounded by the earlier expiry of the org directory and the delegation it carries (still signed in the pool only); a reconnect to another station, and a respawned link, sign again naming it. Past the bound a link sends nothing and logs why. No caller sees a difference today: a station routes a CALL by the connection the ADVERTISE arrived on and reads nothing else from it, and a direct-dialling caller resolves the DHT recordmacula_response:advertise_direct/6,7publishes, which already named the connected station. (#29) macula_client:unadvertise_stream/3no longer crashes the pool. The withdrawal read the stored stream registration as a unary one and failed on its mode.macula_client:advertise/4,5(a registration with no advertisement, as the distribution pool makes) reaches every link's handler table again. The link refused theundefinedadvertisement, so the handler was never registered and the call answered{error, no_healthy_station}.
Added
macula_client:advertise/6andadvertise_stream/7(and the matchingmacula_station_linkcalls) accept an advertisement spec,#{authorization := map(), not_after := integer()}(macula_station_link:advertisement_spec()), which the pool signs per link. A map of any other shape raisesfunction_clausein the caller. A pre-signed advertisement's wire form is still accepted and sent as it is.
[12.2.1] - 2026-09-24
Bug fixes. Wire-compatible with 12.0, 12.1 and 12.2 in both directions.
Fixed
- A handler's
{error, Reason}reaches the caller as{error, Detail}, the handler's own reason. The provider answered it with codeunknown_error, while a caller unwraps onlyhandler_error(as the reply table inmacula_station_linkdocuments), so every handler's refusal reached a caller as an opaque provider error that looked like a platform fault. The provider now sendshandler_error. A caller that meets an older provider'sunknown_errordecodes it as before. (#28)
Removed
- The temporary
[mpong-trace]info logging in the overlay pubsub (hecate_pubsub:on_subscribe,hecate_pubsub_server's relay). It traced the retiredio.macula/beam-campus/hecate/mpong/*_v1topics and logged about 400 lines an hour at every station, long after anything published there.
[12.2.0] - 2026-09-24
Additive and wire-compatible with 12.0 and 12.1 in both directions: nothing here changes a frame.
Added
macula_response:advertise/6takeshandler_timeout_ms: how long a response waits for its handler before the caller is answeredtemporary_relay_failure, 1 to 600000 ms (the longest any caller waits), default 30000. It was a fixed 30 s, so a handler that needed longer had its success reported to the caller as a failure while it went on and finished. Anything else is refused as{error, {invalid_handler_timeout_ms, Value}}. It bounds this node's own wait and is not sent to the station. (#25)macula_publisher:start_link/7takesannounce => false, which publishes the payload alone, withoutpubsub.publish_started_v1andpubsub.publish_completed_v1: one frame per fact instead of three. (#27)
Changed
macula_publisher:start_link/5,6,7returns as soon as the publisher runs. The start announcement and the publish follow inhandle_continue/2; the announcement used to be sent frominit/1, so the caller waited a pool round trip before its publish had begun. A publisher whose start announcement fails now exits afterstart_linkhas returned{ok, Pid}, instead ofstart_linkreturning{error, _}. (#27)
Fixed
- A
macula_subscriberends when its pool dies without saying so. The pool sendsmacula_event_goneonly from itsterminate/2, so a pool that was killed, or taken down by a link, left the subscriber alive, subscribed to nothing and looking healthy. It monitors the pool and stops with{pool_down, Reason}, an abnormal exit its supervisor restarts.macula_pubsub:subscribe_callback/4's receiver ends with its pool too. A process that callsmacula:subscribe/4,5itself should monitor the pool the same way. (#26)
[12.1.0] - 2026-09-23
Added
macula:call/6with#{provider => NodeId}calls one named provider of a procedure and no other. Resolution and the D25 trust check are exactly ascall/5's; only that provider's advertisements are tried, and one with no trusted advertisement by the deadline is{error, {unresolved, provider_not_advertised}}.macula:providers/3,4lists who provides a procedure: each provider whose advertisement passes the same trust check, with the station it serves from, in one bounded DHT lookup. Listing then calling each by name is how a caller gets one answer from every provider.macula:links/1entries carrylast_disconnect: why that seed's link last went down, kept across the respawn that replaced it, with both node ids for apeer_identity_mismatch._macula.station_link.disconnectednames the expected and presented node ids for apeer_identity_mismatch, and_macula.peering.closednames the peer's address (host:port), so a connection refused before its handshake names anyone is no longer anonymous.
Changed
- A
pq_hybridverify is about 18% faster (440 to 362 µs, best of five interleaved runs on one core). The carried RSA key's two well-formedness checks are unchanged in what they accept: its DER is compared against a canonical encoding built directly from the modulus and exponent instead of through the generic ASN.1 encoder, and the modulus's bit length is read from its byte length and top byte instead of a 4096-character base-2 string.
Fixed
macula_quic:peername/1returns the host as a binary; its spec said a string.macula_distput that binary into#net_address{}where OTP expects an address tuple, and called an IPv6 peerinet. It is now parsed, withinet6for IPv6.connect/2,join_mesh/1andmacula_client:opts()documented a pool given nonode_identityas generating one; it loads the node's one stored identity.
[12.0.0] - 2026-09-23
Post-quantum end to end: key exchange AND authentication. Every QUIC
link negotiates SecP384r1MLKEM1024 or SecP256r1MLKEM768 and nothing
classical, from the macula-pqc
crate. Every signature macula makes or checks is ML-DSA-87 on
macula-mldsa: node keys, the
self-signed certificate a listener presents, the binding a dial verifies
against it, and UCAN tokens. The one classical signature left is by
design, the RSA-PSS half of the pq_hybrid composite
id-MLDSA87-RSA4096-PSS-SHA512, which sits beside ML-DSA-87 rather than
instead of it. A test scans src/ and native/ on every run so no other
one returns.
Breaking on the wire: a node on 11.5.0 or earlier cannot connect to this
version, in either direction, and neither key exchange nor
authentication has a classical fallback. Upgrade every node together.
pq_hybrid signatures also break against 12.0.0-alpha.1, which this
release folds in: that pre-release was never published, so this is one
release and not two.
The SDKs for the other stacks do not speak this version yet.
Added
A distribution tunnel names its peer (D29, WP 1.5).
macula_dist_tunnelholds a TLS 1.3 session between the two nodes inside the tunnel and runs the connection handshake end to end inside it: the accepting node in the station role with its TLS key, certificate and TLS-key binding, the dialling node sending CONNECT with its CONNECT key against the node_id it meant to reach. The handshake ismacula_handshake, unchanged and shared with the peering connections; the only difference is that the leaf comes from the session inside the tunnel rather than from the QUIC connection.macula_dist_tunnel_socketmakes a macula QUIC stream look like a socket to OTP'sssl, which is what lets a session run over a carrier that forwards bytes. Neither carrier calls this, and the wiring is parked (Raf, 2026-09-23: distribution over the mesh is a novelty rather than a product), so nothing changes for a running node: the relay and pool paths still refuse to start withoutMACULA_DIST_UNIDENTIFIED_PEER=accept, which remains the gate. Measured before it was built, and it is what removes D29's fallback: OTP 28.4.2'ssslcompletes a TLS 1.3 handshake with the certificate and keymacula_quic:generate_self_signed_cert/2returns, ML-DSA-87 with the private key in RFC 9881's seed form, and signs the CertificateVerify with it.macula_crypto_nifmakes and checks ML-DSA signatures onmacula-mldsa, the FIPS 204 implementation inmacula-pqc, verified against NIST's ACVP vectors:mldsa_generate/1,mldsa_public_key/2,mldsa_sign/4andmldsa_verify/5, for ML-DSA-44, -65 and -87 under OTP's set names. A private key is{seed, Seed}or{expanded, Key}, the form OTP generates. Signing is hedged, with randomness from the OS, and takes a FIPS 204 context string. There is no fallback to OTP: without the NIF these raise. Tested against OTP both ways, on OTP-made expanded keys and on seeds.
Changed
A provider judges a CALL's deadline and runs it once, as it already did a STREAM_OPEN (D7 check 2, D22's tolerance). The signed deadline must lie inside the provider's clock minus 5 minutes and plus 10 minutes, and (
caller,request_id) is held until the deadline plus 5 minutes: a copy with the same request hash gets the stored reply rather than running the handler twice, a copy arriving while the work runs is refusedrequest_copy, and a different request under a held id is refusedrequest_id_reused. There is no nonce store for CALL or STREAM_OPEN, and none is needed: the signed deadline and that window are what stop a replay.A caller may present a capability it was delegated, and a provider follows the chain (D7, WP 1.4).
macula_ucan:authorize/3takes the request's realm id, its procedure and the proofs that travelled with it, and walks the token'sprfto its root: each link's own signature over its bytes as received and its validity window, a parent's audience against the node_id of the child's issuer key,canequal at every step, each capability covered by one the parent granted, the root the issuer the policy names, and every proof used exactly once. A token names at most one parent.macula_ucan:proof_id/1is the id aprfentry holds, the lowercase hex of the SHA-384 of a token's bytes as they travel;macula_ucan:covers/2is the narrowing matrix over the MRI grant formsmri:realm:R,mri:org:R/Oandmri:proc:R/P.A capability names the realm it is for, and a realm id is SHA-256 over its normalised realm name, so a grant is checked against a request with nothing looked up. A realm name is lowercase and compared byte for byte, and the all-zero realm has no name, so no delegated grant covers a request in it. A gated procedure carries an org namespace, the text before its first
/.CALL and STREAM_OPEN carry a chain's proofs in
proofs, inside the part the caller signs: at most 8, at most 256 KiB together, each a byte string and none repeated. A request outside that ismalformed_frame, and so is one carrying a proof no token in the chain names. A request without delegation carries noproofsfield and is byte-identical to one built before the field existed. The vectors are intest/vectors/decoding_rule_v1.json, whose entries now name how they are read invia.macula_node_keysgenerates, signs, verifies and derives ML-DSA-87 keys throughmacula-mldsa, not OTPcrypto(D7, as amended). Signatures and node_ids are as before, and a signature from either side verifies on the other. RSA-PSS, the EU composite's second half, stays on OTP.Building macula without a Rust toolchain now fails at
macula_crypto_nifinstead of skipping it with a warning. Its ML-DSA has no Erlang fallback, so the skipped build compiled clean and gave node keys that could not sign.New ML-DSA-87 private keys are stored as their 32-byte seed (D6, as amended), not the 4,896-byte expanded form. A key file that holds the expanded form loads and signs as before, and keeps its node_id. A key file written by this version does not load on 12.0.0-alpha.1 or earlier, which accept only the expanded form.
The
pq_hybridsignature is now the IETF LAMPS compositeid-MLDSA87-RSA4096-PSS-SHA512, asdraft-ietf-lamps-pq-composite-sigsdefines it, replacing Macula's own composite (D7, as amended). Both halves sign M' = Prefix || Label || len(ctx) || ctx || SHA-512(M) under the labelCOMPSIG-MLDSA87-RSA4096-PSS-SHA512with an empty ctx; ML-DSA-87 now signs with that label as its context string, which OTP cannot do, and RSA-PSS stays SHA-384 with a 48-byte salt. It is proven against the draft's own vector: the draft's signature verifies, and the draft's private key loads as a node key and signs composites whose halves the draft's construction accepts. It still travels under the JOSEalgML-DSA-87-PS384, since JOSE has no name for it.Breaking on the wire for
pq_hybrid: apq_hybridsignature made by 12.0.0-alpha.1 or earlier does not verify on this version, and the reverse. Keys and key files are unchanged, andpq_pureis unaffected.UCAN tokens are post-quantum:
macula_ucanreplacesmacula_ucan_nif(D7).macula_ucan:create/4signs a token with a node key: the header'salgisML-DSA-87inpq_pureandML-DSA-87-PS384, the LAMPS composite, inpq_hybrid;issis adid:keyfor the issuer's key (multicodecmldsa-87-pub, 0x1212, or Macula's own 0x300087 for the composite);audis the audience's node_id in lowercase hex;expis required.macula_ucan:authorize/3checks a token for a verified caller under a policy, verifying the signature over the header and payload exactly as received. The station link authorizes CALL and STREAM_OPEN through it.Breaking: a policy names its issuer by id, not by key.
{ucan_required, IssuerNodeId}takes the issuing node's node_id, and{realm_member_required, RealmKeyId, RequiredCan}the key id of the realm's key. An EdDSA token is refused. Delegation chains throughprfare not followed yet: a token is authorized only when its own issuer is the one the policy names.The macula application refuses to start when
puzzle_difficultyis set, with{bad_config, {macula, puzzle_difficulty, {not_a_setting, Value}}}, whatever the value. The difficulty ismacula_node_keys:puzzle_difficulty/0, one constant for the fleet (D30), and after the removals below nothing reads the setting, so a node that set it would believe it chose a difficulty. Delete{puzzle_difficulty, _}from themaculasection of everysys.config. The check ismacula_node_keys:check_puzzle_difficulty/0.A QUIC listener presents a self-signed ML-DSA-87 certificate, and a dial verifies one way only (D12, D16).
macula_quic:generate_self_signed_cert/2takes the 32-byte seed of a TLS key, a node key of purposetls, and returns that certificate and its PKCS#8 key, both PEM, made bymacula-pqc0.2 and signed withmacula-mldsa. It replacesgenerate_self_signed_cert/3, which took an Ed25519 keypair. A dial checks the listener's TLS 1.3 handshake signature under the key of the one certificate it presents, withmacula-pqc'sKeyPossessionVerifier: no certificate authority, no name and no chain, since none issues ML-DSA certificates. That proves the listener holds the key, and the signed CONNECT/HELLO handshake, checked againstexpected_node_id, is what names the peer.A classical certificate no longer works in either role: a listener given one fails to start, and a dial to a listener presenting one fails.
A station dial is bound end to end, as it was before: the station's challenge carries its identity key's binding over its TLS key, the client checks it against the certificate THIS handshake received, and the CONNECT proof covers the same certificate (
macula_handshake).⚠ The two Erlang distribution dials are the exception, and they are weaker than in 11.x.
macula_distandmacula_dist_relay_clientspeak distribution over themacula-distandmacula-dist-relayALPNs, which run no connection handshake, so nothing binds the key they verified to a node_id. Until this version they verified a webpki chain against the dialled host by default, throughmacula_tls; a webpki chain is no longer possible at all, since no authority issues ML-DSA certificates. Distribution's cookie handshake proves a shared secret and can be relayed by a peer in the middle. The end-to-end tunnel that closes this is the plan's WP 1.5 and D29.So distribution over QUIC refuses to start unless its operator accepts that:
macula_dist:listen/1indirectanddist_relaymode, andmacula:join_dist_relay/1, return{error, {unidentified_peer_not_accepted, #{limit := _, accept_with := _}}}unlessMACULA_DIST_UNIDENTIFIED_PEER=acceptis set, exactly that value.MACULA_DIST_MODE=relay, which carries distribution over the station mesh, is not gated: that handshake names its peer. The setting goes when the tunnel lands.QUIC links now negotiate POST-QUANTUM KEY EXCHANGE, and nothing else. The QUIC NIF builds every TLS configuration from
macula-pqc0.1, the published crate:SecP384r1MLKEM1024first, thenSecP256r1MLKEM768, and no classical group. Its ML-KEM ismacula-pqc's own, verified against NIST's ACVP vectors; the elliptic-curve half isaws-lc-rs. Two nodes on this version negotiateSecP384r1MLKEM1024, the group thepq_hybridprofile declares. A peer offering only classical groups cannot connect, whether it dials or is dialled;native/macula_quictests both, and the negotiated group.Breaking on the wire: a node on 11.5.0 or earlier cannot connect to this version, in either direction. 11.5.0 negotiates rustls's
ringdefaults, X25519, P-256 and P-384, all classical, and this version offers none of them. Upgrade every node together.Key exchange only. The certificates in the TLS handshake are still signed classically:
rustls-webpkihas no ML-DSA.pin_tls_cert => trueis now REFUSED, with{error, {refused, {pin_tls_cert, no_pin_primitive_for_mldsa87_identity}}}. It is refused frommacula:connect/2,call_station/8,call_stream_station/7,put_content_station/5andget_content_station/5, and whether the key arrives in the options map, in a seed map or in a station map.macula_station_linkrefuses it on the seed itself with{seed, {pin_tls_cert, no_pin_primitive_for_mldsa87_identity}}.falseand an absent key are unaffected, deliberately:falseis what every caller passes today, macula-station's outbound links among them, and refusing it would break a live caller for asking for the safe thing. macula-station is not affected either way, because it callsmacula_peering:connect/1directly and never builds a station link.The option had a reader until 11.0.0 removed it with the Ed25519 CONNECT and HELLO frames, and none since. No certificate was pinned on any dial at any value, while the published RPC guide documented a default of
true. It is refused rather than implemented because no pin primitive can express our identity:macula_quic'sverify_pubkeyextracts an Ed25519 SPKI at exactly 32 bytes, station identity is ML-DSA-87, and a node_id is a SHA-256 hash rather than a key. See macula#15.A pinned seed is refused PERMANENTLY, not transiently. The seed-gate refusal fell to
permanent_refusal/1's transient catch-all, so a pool given a seed carryingpin_tls_cert => truescheduled a respawn every second, refused the same seed again, and looped forever on a link that can never start: uncounted, absent fromstatus/1, and visible to a caller only as{error, not_connected}. It is now{permanent, pin_tls_cert_refused}and counted instatus/1'srefused_dials.macula_client:child_spec/3now starts{macula, connect, ...}, not{macula_client, connect, ...}. The supervised start is the path the facade documents for production callers, and it bypassed every check on the facade.A crypto profile declares only fields something reads, bar one that is marked. Removed from
macula_crypto_profile:definition/1, because nothing anywhere read them:tls_cipher_suite,status_signature,binding_digest,content_id_digest,node_id_digestandprofile. Each named a value the code hardcodes at its use site, so the profile could disagree with the node and nothing would notice. A caller already holds the profile, since it is the argument todefinition/1.key_exchange_groupis kept and is a DECLARED TARGET, not a description of the wire. Nothing reads it: the QUIC NIF offers the same groups whatever a node's profile (see the entry above). Sopq_hybrid's declaredsecp384r1_mlkem1024is the group negotiated, because every node offers it, whilepq_pure'smlkem1024never is: no pure ML-KEM group is offered. See macula#13.macula_client:opts()declaresverifyandexpected_node_id, whichinit/1reads and the closed map did not declare.pin_tls_certis deliberately left undeclared, so a typed caller hears it from dialyzer as well as at runtime.One place now decides what a call failure means for trying the same request somewhere else, and it decides two questions instead of one.
macula_station_link:failure_scope/1replacesnot_sent/1and answerscandidate,requestorprovider:candidate— nothing went out and what failed is this link or this station, so another may well work.request— nothing went out and what failed is the request itself, so every candidate refuses it identically and asking one more is waste.provider— it may have reached a provider, so it must never be sent elsewhere.
"Did anything go out" is
provideragainst the other two; "request or candidate" isrequestagainstcandidate.Why it was two places.
not_sent/1arrived inf47a71f3andmacula_direct_dial:sent_or_not/1inf48ef05d, its ancestor, on the same day. The later commit fixed this class one layer up, named the judgement, documented it and tested it, and nothing went back to point direct dial at it. The older copy recognised one error shape where the newer recognised four, and they disagreed on five. Issue #20.What was broken by the disagreement. A pool at its
max_direct_links, or with its new-peer budget spent, or handed a seed it cannot dial, replies before any worker starts and with nothing sent. Direct dial read those as calls that had gone out, returned them to the caller, and left every remaining candidate untried — the fall-through failing in exactly the degraded case it exists for. And in the other direction, a pool call whose payload the wire cannot carry was retried on every link in the pool, each refusing it identically, spending the caller's deadline to collect the answer it already had.A refused dial now says so in its shape:
{error, {dial_refused, Reason}}.macula:call_station/8returned{error, unusable_seed},{error, too_many_direct_links}and{error, new_peer_budget_spent}bare, which fell intofailure_scope/1's catch-all and read asprovider. The wrapper carries the scope so no caller keeps a list of reasons in step with the classifier — a list that must be kept in sync is how the two copies above drifted in the first place.status/1'srefused_dialstally stays keyed on the bare reason: it counts why dials were refused, not what a caller saw.⚠ Migrating: a caller matching those three atoms must now match
{error, {dial_refused, Atom}}.{error, {open_too_large, Limit}}frommacula_station_link:call_stream/6is classified asrequest. It is returned having sent nothing and started no stream, but being unwrapped it fell into the old catch-all, so the SDK reported that a stream may have gone out when the code path guaranteed nothing did. Direct dial's eventual outcome was right for the wrong reason, which is worse than being wrong, because the next caller to ask that question got the opposite of the truth with nothing to warn them.?RECORD_REFUSALinmacula_direct_dialis deliberately not folded in: its subject is a record lookup rather than a call, and merging them would be over-generalising.Direct dial starts from the station that last answered, when it can. A pool remembers the station that answered a procedure and hands it back as a candidate to try before the DHT is asked at all, so a warm call no longer re-pays a
find_recordsfor the advertisement and afind_recordfor the station's endpoint that it paid on its previous call. Measured against the live fleet before the change, those two lookups were about 120 ms of a 289 ms median warm call, and every one of 40 calls over one established link paid them again.It is a head start and never a substitute. The remembered candidate goes through the same candidate loop, the same share of the same deadline and the same trust machinery as any other, and when it fails resolution carries on into the DHT passes exactly as it would have without one. Nothing is skipped except a lookup.
Two things have to hold together for one to be used, and the second is the interesting one. The advertisement it was built from must still be inside the lifetime it was remembered with, and the pool must still hold a live link to that station. The live link is what makes skipping the
station_endpointlookup honest rather than optimistic: the lookup exists to produce an address to dial, and a link the pool is holding right now is better evidence about a station than a signed record up to five minutes old, because a link either exists or it does not and so cannot be stale. With no live link the pool offers nothing and resolution runs as before.The lifetime is the advertisement's own remaining lifetime, taken as a duration and anchored once against the pool's monotonic clock. An absolute expiry would have to be compared later against a wall clock that may have stepped in between; a duration cannot be lengthened or shortened that way, and a client whose clock is a minute out no longer loses a fifth of a five-minute bound to nothing. An advertisement's own expiry is the minimum of the three surfaces it stands on, because
macula_recordrefuses one that outlives its org directory or its procedure delegation withauthorization_outlived.What it cannot cover, stated because the bound is the only control on it: an advertisement SUPERSEDED by one naming a different station, while the remembered one is still inside its own lifetime. The call then goes out to a station that no longer serves the procedure and its answer is returned, where an uncached call would have found the new station. It is bounded by the remembered lifetime and by nothing else, deliberately, because a CALL that has already gone out must not be sent again somewhere else — see
macula_station_link:not_sent/1, which rules that a station'sunknown_next_peermay follow a CALL that reached its provider.A station is remembered only when its CALL was ANSWERED, and only when the DHT resolved it. A call that went out and came back an error proves a route to the station but not that the station still serves the procedure, and a head start that answers is no fresh evidence about the advertisement, so answering never refreshes a horizon.
Every candidate reports which of the two it was and how it ended, as
_macula.direct_dial.candidate_triedwithsource(head_startordht) andoutcome. Agnostic by construction and with no threshold, so a measurement can separate the two without the code having decided in advance what it expects to find.
Removed
- The
verifyandverify_pubkeydial options, and the webpki, key-pin and no-verification modes they chose.macula_quic:connect/4andasync_connect/4refuse a dial carrying either, with{error, {verify_option_removed, Name}};macula:connect/2,call_station/8,call_stream_station/7,put_content_station/5andget_content_station/5refuseverifyin any value, in the options map or in a seed or station map, with{error, {refused, {verify, one_verification_mode}}}; and a peering dial target carrying it does not start, with{error, {target, {verify, one_verification_mode}}}. An option that chose among modes that no longer exist is refused rather than accepted and ignored, which is macula#15's lesson.pin_tls_cert => falsestill passes. macula_tls, the TLS mode module:quic_client_opts/0,1,quic_client_opts_with_hostname/1,quic_server_opts/0,1,get_tls_mode/0,is_production_mode/0,hostname_verify_fun/3,ensure_cert_exists/2,generate_self_signed_cert/1,derive_node_id/1andget_cert_paths/0. It chose between the removed dial modes, generated RSA certificates throughopenssl, and derived a node_id from a certificate's key, which a node_id has not been since 11.0.0. Nothing in macula used the rest.MACULA_TLS_MODE,MACULA_TLS_CERTFILE,MACULA_TLS_KEYFILEandMACULA_TLS_CACERTFILE, and thetls_mode,tls_certfile,tls_keyfile,tls_cacertfile,cert_path,key_path,cert_key_bitsandcert_validity_daysapp env keys, are read by nothing now.macula_identity, the Ed25519 identity of 10.x:generate/0,1,load/1,save/2,public/1,private/1,node_id/1,sign/2,verify/3,puzzle_evidence/1,puzzle_valid/1,2andcheck_puzzle_difficulty/0. A node's keys, node_id and puzzle aremacula_node_keys. Itspubkey()type is replaced bymacula_node_keys:node_id()where a value is a node_id, and bymacula_ucan_nif:issuer_key()for a UCAN policy's issuer, which is still an Ed25519 key.macula_frame:sign/2,verify/2andsignature/1, the Ed25519 frame signature, and the optionalsignaturefield of the frame header. Nothing signed a frame this way: a frame that needs a signature carries it in a signed object (D13) or, inpq_hybrid, as a neighbour signature (D17).macula_crypto_nif:grind_puzzle/1, which ground Ed25519 keys formacula_identity.macula_node_keys:generate/3grinds identity keys.macula_ucan_nifand its Rust crate, the Ed25519 UCAN tokens, replaced bymacula_ucan(above).macula_crypto_nif:generate_keypair/0,sign/2andverify/3, its Ed25519, which onlymacula_ucan_nifused. With them go its Erlang fallbacks and the crate'sed25519-dalekandrand.macula_did_nifand its Rust crate, which built and parsed DID documents for hierarchicaldid:macula:names, signed with Ed25519. D7 retires thedid:macula:prefix, and nothing called it: not macula, macula-station, macula-realm, mcl-om or mcl-echo.
Fixed
rebar3_hexis pinned to~> 7.2.0, so this release can actually publish. It was unpinned, andrebar3_hex7.3.0, published upstream on 2026-09-22, reads NO environment variable for authentication. A tag run fetched it, ignored the workflow'sHEX_API_KEY, resolved no credentials, and died on{badmatch, #{...}}atrebar3_hex_publish.erl:372with no reason printed, having sent nothing to hex. 7.2.0 reads the variable and publishes. ⚠ A--dry-runguard cannot catch this class: a dry run never authenticates, so the dry-run step passes with a placeholder key in the same run whose publish dies. Nothing in this repository changed to cause it, which is the argument for pinning a plugin that sits in the release path.The relay's control channel read a frame length with no cap, and skipped a frame it could not decode. A 32-bit length was taken as a promise about bytes that had not arrived: a length of 4 GiB, from the relay or anything able to write on that stream, is a reader that waits and holds everything arriving meanwhile until the node dies, and nothing bounded it. A frame that failed to decode was skipped with a warning and reading carried on, so the next bytes read as a length were the middle of something else and one bad frame became an endless run of them on a connection that looked alive. Both now end the connection, as a closed control stream already does. The cap is 65,536 bytes, the same the handshake's reader uses, judged on the header alone.
macula_dist_relay_protocol:decode_buffer/1answers{ok, Messages, Rest}or{error, Reason}, andmax_frame_bytes/0is exported.A provider went dark before its authorization expired.
macula:advertise/5builds a procedure advertisement with its type's own 5-minute lifetime, which knows nothing about the org directory and delegation it carries, and signed it unbounded. Both verifiers refuse an advertisement that ends after the earlier of those two expiries,macula_record:verify_authorization/3here and macula-station's at admission, so in the last 5 minutes of every authorization window a provider signed advertisements its own pool then refused with{provider_authorization, {error, authorization_outlived}}, and stopped advertising early. The advertisement is now signed under that earlier expiry as its bound, so it ends with its authorization rather than after it. The seam entrysign_node_recordinadvertise/5's andprovider_authorization/4's options takes the bounded arity: a caller that overrides it passes afun/3now.A record signed under a
not_afterbound outlived the bound by its own age.macula_client:sign_node_record/3wrote the bound into the record asexpires_atand then refreshed it, and a refresh keeps a record's LIFETIME: the bound was re-anchored to a second, later clock read, and the record ended at the bound plus however long it had sat unsigned. Signed straight after building, that was a millisecond; built a minute before it is signed, a minute, and nothing bounded the gap.macula_record:refresh/3now stamps the record and ends it on ONE clock read, at the earlier of the record's own expiry and the bound, and refuses a bound at or before that read with{error, not_after_passed}rather than signing a record whose expiry precedes its own start. A caller capping a record against a delegation's expiry got a record the delegation no longer covers; anything already signed that way stands and expires on its own.A direct-mode distribution listener could not load any certificate.
macula_distpassedcertfileandkeyfiletomacula_quic:listen/3, which readscertandkey, so it always tried to open a file named "undefined" and the listener failed to start. It now passes the right keys, and makes its certificate as everything else does: self-signed ML-DSA-87 on a fresh TLS key, with the key file its owner's alone (D12, D29). A certificate directory that already holds the RSA pair the old code wrote (generation ran even though the listener then failed) must be cleared: the key loader refuses that pair.
[11.5.0] - 2026-09-21
A minor rather than a patch: station_endpoint_expired is a new error a caller
can receive, which is a behaviour change. See "Migrating" below.
Two of these were live defects in running deployments, not tidy-ups: a dial that discarded the verification its caller asked for, and a pinned route that silently stopped existing after a link bounce.
This is also the first release whose test suite, cross reference analysis and success typing ran in continuous integration rather than only on a maintainer's machine.
Added
macula_stream_sessions:sessions/1— the number of served sessions one caller holds now, the countmax_served_sessions_per_calleris decided on.sessions/0already reported the node's total; this reports one caller's, which is the quantity the per-caller cap is about and the one a caller-scoped observation needs.
Fixed
A direct-dial link now keeps its trust options across a respawn. A direct dial names its station as a URL binary and passes
expected_node_idas an option (macula:call_station/8), and the link folds that option into the seed at start. The pool kept those options nowhere, so when a link bounced, the respawn a second later restarted the seed without them, the link found no pin, and its start was refused with{seed, expected_node_id_required}, counted asseed_without_expected_node_id.Nothing unpinned was ever dialled: the refusal is the trust model working, and it is counted by name. What was lost is the route to that station, until something dialled it again. A pool whose seeds are configured MAPS was never affected, because a seed map carries its own pin; only a direct-dial URL target was, and only after a bounce, which is to say exactly when the network is already disturbed and a route quietly ceasing to exist is least welcome.
Observed in a running deployment during a restart: a link went down and its replacement refused its own seed one second later.
A station endpoint that resolves to an EXPIRED record now reports
station_endpoint_expired, notstation_endpoint_not_found. Both the per-candidate path and the final reporting path mapped an expired record onto the not-found atom, so a caller could not tell "this station never published an endpoint" from "this station served one and our own clock check refused it as stale". Those are different faults with different fixes, and the second one reads as the first.This matters more than a wording change because the window is narrow. A
station_endpointrecord carries a five minute TTL andmacula_recordallows a further five minutes of clock tolerance, so a record verifies for ten minutes from creation and is refused as stale after that. A fleet whose endpoint republish is slower than that window, or a caller whose clock drifts, sees a steady stream of "not found" for records the station is serving correctly.resolve_station_endpoint/2,3returns the barestation_endpoint_expired;call/5,6and the content calls return{unresolved, station_endpoint_expired}. A caller matching onstation_endpoint_not_foundto mean "stale or absent" must now match both atoms. The absent case is unchanged, and an expired record is still asked about again until the deadline, as before.A
lifetime_too_longorlifetime_reversedrefusal on a station endpoint record now ends the lookup instead of being retried to the deadline. Both are properties of the record itself, so asking the same station again returns the same refusal; they were missing from the refusal macro, fell through to the lookup-failure clause, and were treated as a transient transport fault. A caller spent its whole resolve budget re-asking a question whose answer could not change.The cost was not only the wasted budget. Because the lookup carried on, a later reply could resolve and be dialled past a refusal that should have stopped the resolve outright.
A peering dial now uses the
verifyits target carries.connect_opts()accepted the key,macula_station_link'sopts()documented it andmacula_client/macula/macula_content_transferall forwarded it, but the dial builder passed the literal{verify, none}and discarded it, so a caller asking forwebpkigot an unverified dial and no error. The default staysnone, which is what a station dial needs: a station's leaf is self-signed or issued by an unrelated PKI, and the signed handshake, not the chain, binds the connection to the node_id dialed.Callers that already pass
verify => webpkiget chain verification from this release on, where before it was silently ignored. A dial to a self-signed or hostname-mismatched peer that used to succeed will now be refused withinvalid peer certificate. Passverify => noneexplicitly to keep the previous behaviour.This was observed in a running deployment: seed maps configured with
verify => webpki, and the next line in the node's own log readingUNVERIFIED dial ({verify, none}).
Migrating
station_endpoint_expired is a new error atom. It is the only reason this
release is a minor rather than a patch.
Before 11.5.0, an endpoint record that was found but refused as stale was
reported as station_endpoint_not_found, the same atom as "no such record".
Those are now distinct:
| Condition | Before | From 11.5.0 |
|---|---|---|
| No endpoint record at all | station_endpoint_not_found | station_endpoint_not_found |
| Record served, refused as stale | station_endpoint_not_found | station_endpoint_expired |
Who needs to change anything: only code that matches
station_endpoint_not_found and means by it "stale or absent". That match now
misses the expired case. Match both atoms, or match the {unresolved, _} shape
with a catch-all.
resolve_station_endpoint/2,3 returns the bare atom; call/5,6 and the content
calls return it wrapped as {unresolved, station_endpoint_expired}.
Nothing else changes: the absent case is untouched, and an expired record is
still asked about again until the deadline exactly as before. A caller that
already treats every {unresolved, _} as "could not resolve" needs no change
at all.
[11.4.0] - 2026-09-18
Added
macula:provider_authorization/3,4— the pool's own D25 provider authorization (the realm-signedorg_directoryand the org-signedprocedure_delegationnaming the pool's node id, fetched from the DHT and verified against the realm key the pool pins), as theauthorizationoptmacula_direct_dial:publish_advertisement/5andmacula_response:advertise_direct/6,7embed in the direct-dial record.advertise/5resolved this chain for the wire frame only; a caller that publishes the record itself needs it explicitly, since the station refuses an org-namespaced record without one (no_authorization).- The provider-advertisement resolution now reads its DHT calls from a
provider_io/0seam (per-call overridable inadvertise/5andprovider_authorization/4'sOpts, the same discipline asmacula_direct_dial:dial_io/0), andadvertise/5'sOptsaccepts anadvertisefan-out override.
[11.3.1] - 2026-09-17
Fixed
macula_node_keys:sign/2signs the ML-DSA-87 half with its private key as{expandedkey, Binary}— the form both supported OTPs accept. OTP 28 refuses the bare expanded-key binary (MLDSA key not 2-tuple) and OTP 29 refuses the{Pub, Priv}pair, so a key generated on either OTP now signs on both. Keys already on disk are unchanged: the stored private half was always the expanded-key binary.
[11.3.0] - 2026-09-17
Added
- Conn-level dead-peer detection in
macula_peering_conn: a connection started withliveness_interval_msprobes the peer with a_macula.pingCALL on the all-zero realm every interval and accepts ANY verified reply naming the probe's request_id as proof of life;liveness_max_missesunanswered probes in a row close the connection aspeer_liveness_lost. Opt-in: a link that already probes at the application layer (the SDK pool link) leaves it unset. This reaps the dead-but-healthy connection class — a peer VM that dies outright keeps answering keep-alive ACKs at the transport level, so the QUIC idle timer never fires — within interval × misses. A CALL is understood by every released peer, so a mixed-version fleet answers the probe safely.
[11.2.0] - 2026-09-17
Added
- Pool provider advertise:
macula:advertise/5andadvertise_stream/6resolve the pool's own D25 provider authorization — the realm-signedorg_directoryand the org-signedprocedure_delegationnaming the pool's node id, both fetched from the DHT — sign the advertisement, verify it against the realm key the pool pins at connect (realm_trust), and send ADVERTISE frames to every link (drained on handshake, replayed on link respawn).unadvertise/3sends the UNADVERTISE withdrawal through the custody-safe pool signing paths. A missing piece of the chain fails fast as{error, {provider_authorization, _}}: no org namespace, a missing chain record, or an unpinned realm key. Provider pools run a provisioned puzzle-solved identity plusrealm_trustat connect.
Fixed
macula:put_record/2andmacula:find_record/2classify the DHT handlers' atom replies in their wire form —{text, <<"ok">>}and{text, <<"not_found">>}— so a put's acknowledgement and a find's miss no longer surface as an unexpected reply (D26 makes no atom on the wire).
[11.0.0] - 2026-09-16
Added
macula_crypto_profile: the two post-quantum profiles,pq_pureandpq_hybrid, each with its key exchange group, TLS signature scheme, cipher suite, signature algorithms and digests.macula_node_keys: a node's identity, CONNECT and TLS keys for its profile. ML-DSA-87 private keys are stored in their expanded form and RSA-PSS-4096 keys as DER. A key file is restricted to its owner before the key is written into it.load/3refuses a key file its group or others can read, a key saved for another purpose or profile, a stored public key that differs from the one derived from its private key, and a key that fails a sign-and-verify round trip.macula_node_keys:sign/2,verify/4andpublic_key/1: ML-DSA-87 alone in the US profile, and Macula's composite ML-DSA-87-PS384 in the EU profile, valid only if both halves verify. Verification refuses malformed input without raising.macula_node_keys:node_id/1andnode_id/2: node_ids per plan decision D5, SHA-256 over the labelMACULA-NODE-ID-V1, the profile name and the identity key as carried. The reference vectors match Go, Rust and Python.macula_node_keys:key_id/1andkey_id/2: the key id of a key that is not an identity key, SHA-256 over the labelMACULA-KEY-ID-V1, the profile name and the key as carried; an identity key's key id is its node_id. Realm, org and foundation keys are purposes of their own, with the identity key's algorithms.Private keys stay out of status output and of crash and diagnostics reports. Every process that holds a key formats its status through
macula_node_keys:redacted/1, which replaces each key's private half withredacted. The application adds the primary logger filtermacula_key_redactionon start and removes it on stop: in report events of theotpandmaculadomains it redacts every key the same way, and a stack frame of a Macula module shows its arity in place of its arguments, since those can hold a key. Frames of other applications' modules keep their arguments.macula_signed_object: the signed objects of the post-quantum records and frames.sign/3andsign_held/3sign fields under a label over the label, a zero byte, the SHA-384 of the key as carried and tbs, addingalg.verify/3andverify_held/4check the shape, the carried key, the signature over tbs as received, the decoding rule andalg, without raising.encode/1anddecode/1give the wire form.macula_recordin the signed-object format: a record is{key, tbs, signature}underMACULA-PQ-RECORD-V1, signed with a node key whose purpose fits its type and named by its key id.verify/2,3refuses a record over 256 KiB, a malformed tbs, a clock outside five minutes, a payload that breaks its type's rules and a payload naming another signer. Storage keys derive underMACULA-PQ-STORAGE-KEY-V1. Tombstones and procedure advertisements take the design's pinned payloads, andverify_authorization/3checks an advertisement's org namespace and its org directory and delegation. It replacesdecode/1,verify/1,procedure_key/1,verify_delegation_chain/4andverify_advertisement_cert_chain/3.macula_framedecodes every frame under the post-quantum decoding rule, and a frame type's own fields through a fixed table (D26): a frame type, a field or an enum value the table does not list is refused, and payloads keep the one key form, the same on every node. Records in STORE, VALUE, REPLICATE and HyParView frames travel as their wire bytes.check_payload/1refuses what the decoding rule refuses.macula_node_keys:generate/3withpuzzle_difficulty, andpuzzle_solved/2: an identity key whose node_id starts with that many zero bits. Each try makes a new ML-DSA-87 half; a hybrid key keeps its RSA-PSS half, since the node_id covers both halves.macula_record_cbor:decode_strict/1: decodes one CBOR item under the post-quantum decoding rule, without raising. It refuses bytes after the top-level item, map keys other than text or integers, duplicate keys, invalid UTF-8, nesting deeper than 64 levels, negative integers below -2^63, and malformed input.decode/1is unchanged.macula:field/2,field/3andtext/1: read fields of maps a peer supplies (D26), whose text keys and values arrive as{text, Bin}. A field is looked up as{text, Name}, then as the atom, then as the binary, so maps handed over in process read the same way. The distribution pool reads its tunnel RPC payloads through them.macula_key_bindings: bindings of a node's TLS and CONNECT keys to its identity key, and the status statements that keep a binding in force, as the handshake frame design lays them out. Each travels as its signedtbsbytes and a signature. A verifier checks the signature over the bytes it received before decoding them strictly, refuses an unknown key or a field of the wrong type or length asmalformed_frame, and checks validity with 5 minutes of clock tolerance.macula_frame:encode_bytes/1andparse_stream_bytes/1: frame CBOR bytes with and without the length prefix, exactly as sent and received, for the post-quantum handshake.decode/1shares their length-prefix code.macula_handshake: the post-quantum connection handshake frames, built as CBOR bytes and checked as received. The client checks the challenge before it signs the proof. The station checks CONNECT, the node_id puzzle underoff,log_onlyorenforcebefore any signature, and returns the HELLO bytes to send; a refusing HELLO carries one coarse refusal code. A key that would serve a second purpose is refused askey_purpose_reuse.macula_node_keys:carried_key_well_formed/2andsignature_bytes/1: the one carried form of a key per profile, and the signature size per profile.macula_key_bindings:verify_status/5returns when the statement expires.macula_quic:peer_leaf/1andpresented_leaf/1: the leaf certificate DER of a connection's own TLS handshake. A dialed connection reports the leaf it received, byte for byte, and an accepted connection the leaf it presented.macula_quic:reload_certificate/3: a listener loads a new certificate and key from files and presents them to the connections it accepts from then on. Each certificate is a generation of its own, and an accepted connection keeps the leaf of the generation it was accepted with. A reload that cannot read its files, or whose key does not match its certificate, returns an error and keeps the current certificate.macula_peeringconnections run the post-quantum handshake ofmacula_handshakewith the node's identity key and itsmacula_statement_issuer. After HELLO each side sends a status frame at every reissue of its statement. A connection closes withstatus_expiredonce the peer's statement is 5 minutes past its expiry, withbinding_expiredat the peer binding's not_after, and with a check's reason when a status frame fails that check. Close reasons are local: the controlling process hears them indisconnected, and the peer does not. A station underlog_onlyreports an unsolved puzzle as_macula.peering.puzzle_unsolved. Inpq_hybrida connection neighbour-signs every control frame it sends and checks every one it reads, for the connection hash and the seq in that direction, and closes on a refusal.macula_diagnostics:bounded_event/3: a diagnostics event logged at most once per 10 seconds per event name, node-wide. An event inside that window is counted, and the next line for the name carries the latest properties withsuppressed, the number held back since the line before; once per window the table's owner,macula_diagnostics_bound, logs a count a burst left. Callers update the counts table themselves.macula_peeringconnections log_macula.peering.closed,_macula.peering.handshake_timeoutand_macula.peering.puzzle_unsolvedthrough it.macula_record_uuid:v7_monotonic/1: the version of a record a node signs. Its rand_a bits count within a millisecond from a random 11-bit seed, and each version is the larger of that fresh value and the last one issued plus one, so the versions a node issues strictly increase, also across a wall-clock step back, and a tombstone built in the same millisecond as its record replaces it. A version is an order, not a time.macula_recordsigns new, refreshed and withdrawing records with it.macula_peering:peer_identity/1: the peer's node_id, its identity key as carried, the profile and its capabilities, once the handshake has completed.macula_handshakeresults carry the not_after of the peer's binding, andmacula_frame:read_wire/1reads a frame from its decoded CBOR value. On an open connection a frame is decoded once and routed by its frame_type (macula_handshake:open_frame_kind/1andread_status_wire/2): a handshake frame, a framemacula_framerefuses and bytes that are not CBOR close it asmalformed_frame.HyParView frames are bounded: a
peer_sampleholds at most 7 node_ids, a SHUFFLE or FORWARD_JOINttland a FORWARD_JOINarwlare at most 8, and aprwlis at most itsarwl. A frame outside these ismalformed_frame, and the constructors refuse to build one. A neighbour places at most 20 node_ids per minute in the passive view, a token bucket with one back every 3 seconds counting only node_ids new to the view. A FORWARD_JOIN places its new member when itsttlequals the receiver's own PRWL. A SHUFFLE_REPLY is merged only while a SHUFFLE sent in the last 30 seconds has no reply yet.macula_hyparview_protoreturns{refused, Neighbour, Kind}for a frame past the allowance and for an unsolicited SHUFFLE_REPLY, andmacula_frame:charged_refusal/1charges both kinds.A link-carried
macula_streamthat receives a frame of a type that belongs on the control stream rejects its peering connection withmalformed_frameand ends, in either profile.macula_frame:control_frame/1names those types.macula_frame:verify_publication/3sizes a refusal for time:{not_yet_valid, AheadMs}and{expired, PastMs}, the milliseconds past the moment its rule starts refusing.charged_refusal/1charges them only beyond 10 minutes.hecate_plumtreetakes its clocks from the caller:process/4with wall-clock and monotonic milliseconds,publish/3with the wall clock. A neighbour is on at most 1,024 open missing entries: an IHAVE past that is not recorded, gets no GRAFT and returns{refused, Neighbour, ihave_allowance}.expired_grafts/2takes a neighbour off an entry whose GRAFT it left unanswered for 10 seconds and returnsgraft_unanswered. A GOSSIP of an id, verified or refused, ends its whole entry at no charge to its announcers, and a refused GOSSIP returns its refusal.macula_frame:charged_refusal/1chargesihave_allowance,graft_unansweredandwrong_realm.macula_clientbounds the links a pool holds and the new peers it dials. With more seeds thanmax_seeds(default 16) a pool does not start, andconnect/2returns{error, {too_many_seeds, Given, Max}}. A fresh direct dial pastmax_direct_links(default 8) is refused with{error, too_many_direct_links}.new_peer_budget(default 16) is the most new peers per 15 minutes, each counted once by its normalized seed: past it a fresh direct dial is refused with{error, new_peer_budget_spent}and a discovered station is left for a later discovery run. The configured seeds never spend it.status/1counts refused dials by reason inrefused_dials, and each reason is logged at most once a minute with its count. Each limit, and station discovery'smax_links, is an integer from 1 to its cap (64 seeds, 64 direct links, 256 new peers, 64 discovered links); any other value, an atom included, does not start the pool, andconnect/2returns{error, {invalid_link_limit, Key, Value}}. A seed's host is compared in canonical form: its ASCII letters lowercased, without the brackets around an IPv6 literal, and an IP literal in one text form, with an IPv4-mapped IPv6 address as its IPv4 address. Other bytes, a trailing dot included, are kept, so two names DNS tells apart are two peers. A direct dial or a discovered station whose seed has no text host, or no port from 1 to 65535, is refused withunusable_seedand counted, and the pool keeps serving.macula_frame:stream_bytes/2signs and encodes a frame built for a dedicated stream: a CALL or STREAM_OPEN, a RESULT or provider ERROR, a station's relay error, or either side's stream frame under its verified STREAM_OPEN. It returns{ok, StreamBytes}, or an error with nothing to write. Before signing:{unknown_build_key, Key}for a field the frame does not have;unsignablefor a key that is not the identity key of the sender the receiver verifies, or a stream frame without its verified STREAM_OPEN;{not_allowed, Type}for a caller's STREAM_REPLY, or a caller's STREAM_DATA in a server_stream;{text_too_long, Field}for a code over 64 bytes, or a provider detail or STREAM_ERROR message over 256 bytes;{invalid_text, Field}for text that is not a UTF-8 binary;relay_code_outside_its_set; and{unsupported_payload_type, Type, Path}for a payload, body or reply the wire cannot carry. After encoding:frame_too_largefor a frame over the 16 MiB cap. A build that leaves out a field its frame requires, has a field of the wrong size, names a stream frame type outside the four, or has aseqoutside the protocol's range raisesfunction_clause, and any other field outside its type, range or set raises too.written_bytes/1gives the bytes of astream_bytes()and refuses anything else.macula_frame:parse_for_relay/2parses bytes a relay received on a stream: each whole frame that passes the checks ofparse_received/2comes with a unit holding a copy of exactly the bytes received for it, a frame whose fields its type refuses comes back refused with no unit, and a length header over the cap or a frame that does not decode ends the parse.macula_peering:relay_on_stream/2andasync_relay_on_stream/2,3write only such units, throughmacula_frame:relayed_bytes/1, so a relay writes nothing its reader did not accept.macula_stream:controlling_process/2hands a stream to another process, which the stream then ends with. Only the stream's owner can hand it over; anyone else gets{error, not_owner}. When a stream's session ends, with both sides closed, an abort, or its link lost, its owner gets{macula_stream, ended, Stream, How}once,Howbeingclosed,{error, {Code, Message}}orpeer_down. An owner the stream is handed to after that is told at once.controlling_processis also one of the stream functionsmacula_streamertakes instream_io.include/macula_quic_error_codes.hrlnames each QUIC application error code macula sends when it resets or stops a stream, or closes a connection:QUIC_CODE_CANCELLED(0),QUIC_CODE_LINGER_EXPIRED(1),QUIC_CODE_REFUSED(2),QUIC_CODE_STREAM_PROTOCOL_ERROR(3) andQUIC_CODE_REFUSED_BUSY(4), when the node has no room: for a connection a station closes because it has no handshake slot free, or a relayed stream it resets because the stream's reader does not take data in time. The codes on the wire do not change.macula_quic:close_connection/3closes a connection with an application error code and a reason of at most 256 bytes, which the peer reads withmacula_quic:close_reason/1. A code that does not fit a QUIC variable-length integer is refused with{error, error_code_out_of_range}and a longer reason with{error, reason_too_long}; the connection stays open.close_connection/1still closes with code 0 and the reasonclosed.macula_quic:close_reason/1says why a connection closed, oropenwhile it is open:{application_closed, Code, Reason}for the peer's application close,locally_closedwhen this side closed it, and the transport's own reason otherwise.macula_peering:async_send_on_stream/3writes a frame onto a dedicated stream without waiting, so a peer that stops reading cannot hold a process that serves several links. It checks, signs and encodes the frame assend_on_stream/3does, and refuses what that refuses, then queues it. With 1 MiB already unwritten on the stream it queues nothing and returns{error, busy}, and the caller gets{quic, send_ready, Stream, undefined}when it may send again.async_send_on_stream/4takes a tag: for a frame it queued, the caller gets exactly one{quic, send_complete, Stream, Tag}once the frame's bytes are written, or{quic, send_incomplete, Stream, {Tag, Reason}}when the stream is reset, closed or fails first.macula_quic:async_send/3isasync_send/2with such a tag.
Changed
Content ids are SHA-384 (D24):
<<2, Codec, Hash:48>>, 50 bytes, with byte 0 as the hash tag. A tag 1 (BLAKE3) id is refused on fetch, in manifests, for chunks and in content announcements. New blocks, manifests and chunks are hashed with SHA-384, a manifest namessha384as its only hash algorithm, andmacula_manifest:chunk_mcid/2replaceschunk_mcid/3.The
maculaapplication starts only withcrypto_profileset topq_pureorpq_hybridin its environment. A missing value, an unknown value or a list of profiles makes the start return an error. There is no default. The test configuration,config/test.sys.config, setspq_pure.macula_cluster:start_cluster/1returns{error, {unknown_strategy, Strategy}}for a strategy other thanauto,gossiporstatic, and does not start distribution.macula_peering:connect/1andaccept/2takeidentity, amacula_node_keysidentity key, andissuer, the node's statement issuer. A dial'stargetrequiresexpected_node_id, the station's node_id, and a station requirespuzzle => #{mode => Mode}. A connection without them does not start.connectedandhandshake_completecarry the peer's node_id. A connection sends frames as their producers built them, adding only the neighbour signature of a control frame inpq_hybrid.macula_hyparview_proto:build_shuffle/2takes the view and returns it with the SHUFFLE recorded, and the send to a random active neighbour, with a sample of the view.ctx()carriesnow, in monotonic milliseconds.A station link's liveness probe is a
_macula.pingrequest to the station it is connected to, and the link keeps the probe's request. Only a reply that verifies against that request clears the probe: a RESULT or provider ERROR throughmacula_frame:verify_reply/3, or a relay ERROR reported by that station throughmacula_frame:verify_relay_error/4. The link finds the request a reply answers by the ids it claims,macula_frame:claimed_reply_ids/1. A reply for a request the link does not hold, or one that does not verify, clears nothing; the link counts it by reason and logs the count at most once a minute. As before, the connection closes afterliveness_max_missesunanswered probes, two by default.A station link's calls name their target (D25).
macula_station_link:call/6,7takestation, the station the link is connected to, or a provider's node_id, and replacecall/5,6. The link signs the CALL and keeps the request. A reply completes the call only if it verifies against that request, found by the ids it claims, as for the liveness probe; any other reply is counted and the call stays pending. A call returns:{ok, Payload};{error, Detail}for a provider'shandler_error;{error, {call_error, Code, Detail}}for another provider code, withCodeandDetailas binaries andDetailundefinedwhen absent;{error, {call_error, unknown_next_peer, undefined}}when the station has no link to the target.
A payload no frame can carry is refused before sending as
{error, {refused, Reason}}. So is a procedure over 512 bytes or not valid UTF-8, asmacula_frame:text_checked/2bounds it. The timeout is 1 to 600000 milliseconds. Every call ends with one of:- its reply;
- its timeout;
{error, {disconnected, Name}}when the connection closes, or{error, {peering_exit, Name}}when the connection's process exits;{error, {link_stopped, Name}}when the link stops for any other reason, which also answers a call that had not reached the link yet.
Nameis the reason's name asmacula_reason_name:text/1gives it, such as<<"normal">>,<<"shutdown">>or<<"crashed">>, and nothing else of the reason reaches a caller. A reply after the timeout is counted like one for a request the link does not hold.not_sent/1is true only fornot_connected,noprocand{refused, _}.macula:call/5reaches a provider through its verified advertisement (macula_direct_dial), and so do station discovery and the distribution pool's tunnel calls.macula:call_station/7,8andmacula_client:call_station/7to10take the provider's node_id asTarget, after the station.macula_client:call_linked_station/5replacesmacula_client:call/5and calls a station the pool is linked to, as the DHT functions do.macula_direct_dial:call_stream/6takesdial_timeout_msfrom 1 to 600000 milliseconds, andmacula_request:start_link/6,7,8andstart_link_direct/6,7,8refuse a timeout outside that range where the request starts.A pool pins realm trust when it starts:
macula:connect/2'srealm_trust => #{RealmId => RealmKey}, each realm's public key as carried. The pool refuses to start, before any link, on a realm id that is not 32 bytes or a key not well formed for the node's crypto profile ({realm_trust, invalid}), or on a key of the other profile ({realm_trust, profile_mismatch}). Direct dial checks an org namespaced advertisement against the key pinned for its realm alone (macula_client:realm_key/2). A caller looks up no tombstone, so a delegation its org withdraws is honoured until it expires, at most six hours.A station link's stream sessions use the post-quantum stream frames and name their target.
macula_station_link:call_stream/6takesstationor a provider's node_id and replacescall_stream/5. The link signs the STREAM_OPEN with its identity key, and the session's stream signs and verifies its own frames under that open. A build the frame refuses returns{error, {refused, Reason}}, and an open overmax_stream_open_bytesreturns{error, {open_too_large, Limit}}, both before a stream starts. A session's frames reach it through the dedicated stream they arrive on.A provider verifies a STREAM_OPEN, serves only one addressed to its own node_id, and admits it once per caller and request id through the pool's request admission, before its procedure's policy or handler. An open that does not verify, or names another node, closes its stream with nothing written. Any other refusal is a STREAM_ERROR signed under the open it refuses:
request_copy, the admission refusal's name,unauthorized,not_found,mode_mismatchfor an open in a mode other than its procedure's,too_many_sessions,refusedfor a second open on one stream, orunavailable. A caller's chunk in aserver_streamis refused as a malformed frame and no longer ends the session. A procedure's policy reads the request'stoken, and its audience check takes the caller's key id.macula:call_stream/5resolves the provider through its verified advertisement, asmacula:call/5does.macula:call_stream_station/7andmacula_client:call_stream_station/7take the provider's node_id asTarget, after the station, and direct dial passes the provider it resolved.Direct dial refuses the 10.x trust options by name, with
{error, {removed_option, Key}}, before anything is looked up, dialed, registered or published.macula_direct_dial:call/6andcall_stream/6refuseverify_cert_chain, andrealm_trust, which the pool now pins.macula_direct_dial:publish_advertisement/5and theadvertise_direct/7functions ofmacula_responseandmacula_streamerrefusecert_chain. The realm keys a pool pins andauthorizationreplace them.A station link delivers the overlay frames D17 leaves unsigned, as
macula_frame:relayed_without_signature/1names them, from anoverlay_relayenvelope, with the envelope's origin as their sender whatever the inner frame names. A relayed frame of any other type is dropped, and so is a relayed payload that is not exactly one frame; the link carries on. The relayed frames a link drops are counted by kind and logged at most once a minute per kind. The envelope reaches the link only through its own connection, which inpq_hybridchecks the station's neighbour signature on it first.A station link logs its
_macula.station_link.disconnectedand_macula.station_link.peering_exitdiagnostic events atnotice, so a lost connection shows at OTP's default primary level. Its other diagnostic events stay atinfo. Every station link diagnostic event names a reason only bymacula_reason_name:text/1.A pool runs one request admission,
macula_request_admission, for the requests all its links receive, and ends when the admission ends. Its limits come from therequest_admissionpool option, then themaculaapplication environment, then the defaults: 256 entries per caller, 1024 per link's share, and 256 KiB of stored reply bytes per caller and 16 MiB in total. A limit outside its range, a quota per caller above the share, or reply bytes per caller above the total refuses the pool start. A link's share is its normalized seed.macula_station_link:start_link/1requiresadmissionandshare.macula_identity:load/1andmacula_owner_only_file:read/1accept only a file that belongs to the user the node runs as, besides its mode. A file of another owner returns{error, {file_owner, #{file => Path, owner => Uid, required => NodeUid}}}and is never reported as missing, so a caller that makes a new identity only on{error, enoent}makes none. A host without user ids skips the owner check. Before upgrading, give every identity key file, and every other secret macula reads this way, to the user the node runs as.A station's pubsub registry keeps a realm's
hecate_pubsub_serveronly while something holds the realm. With the registry'sidentityset, only a SUBSCRIBE starts a server for a realm without one; an UNSUBSCRIBE or EVENT for such a realm gets{ok, []}and starts none, andhecate_pubsub_registry:relay_publish/3still returns the station-signed EVENT for fan-out to peer stations but starts no process. When an UNSUBSCRIBE orpurge_subscriber/2takes a realm's last subscription, its server stops and its place frees. A realm registered withregister/3is pinned and stays, also when its server stops. At mostmax_subscribed_realmsrealms, a registry start option of 1000 by default, are materialised by SUBSCRIBE at once; a SUBSCRIBE past that gets{error, too_many_realms}and starts no server. Pinned realms do not count, with or without a server.hecate_pubsub_server:relay_event/2builds the EVENT a station relays for a PUBLISH, without a server.A dedicated stream a peer opens may start with a STREAM_OPEN of at most 1 MiB, set with the
max_stream_open_bytesmacula application env. A longer first frame closes the stream as soon as its length arrives, with no STREAM_ERROR.macula_station_link:call_stream/5, and through itmacula_client:call_stream/5andmacula:call_stream/5, refuse an open whose signed STREAM_OPEN would be longer with{error, {open_too_large, Limit}}and send nothing, so the caller learns at once instead of waiting out its deadline. Send bulk data as chunks once the stream is open.A node serves one verified caller at most 16 stream sessions at once, and all callers together at most 1000, set with the
max_served_sessions_per_callerandmax_served_sessionsmacula application env. The cap is kept per caller, not per link, because a link to a station carries every caller that station sends: one busy caller cannot take the places of the others. A STREAM_OPEN past either cap gets a STREAM_ERROR with codetoo_many_sessionsand runs no handler. A session's place frees when its stream ends, and the counts hold across a restart ofmacula_stream_sessions, which counts the sessions and the refusals by reason and logs refusals at most once perserved_session_refusal_log_interval_ms(60000 by default). When the counter does not answer within a second, a STREAM_OPEN gets codeunavailableinstead, and the link carries on.A dedicated stream a peer opens stays open only once it carries a session. A stream whose first frame is not a STREAM_OPEN, whose STREAM_OPEN does not verify, or that brings no whole frame within
dedicated_stream_open_timeout_ms(10000 by default) closes without a STREAM_ERROR.A dedicated stream carries one session. A STREAM_OPEN on a stream that already carries one, served or opened by this node as a caller, gets a STREAM_ERROR for its own stream id with code
refused, and the session already on the stream keeps it.A STREAM_OPEN refused with
not_found,unauthorizedortoo_many_sessionscloses its dedicated stream once the STREAM_ERROR is written. The link keeps nothing for that stream and takes no more frames from it, including the rest of the read the refused STREAM_OPEN came in.A stream holds at most 16 MiB of memory for chunks no reader has taken. A queued chunk is copied, so it keeps none of the frame it arrived in, and it counts for the memory it takes: its bytes, a decoded term's heap size, and the cell that queues it, so empty chunks count too. A chunk that would take the stream past the bound ends the session: the peer gets a STREAM_ERROR with the new code
resource_exhausted, and the owner is told. That code says the receiving side had no room for what was sent, and a later session may succeed;stream_protocol_errorsays the peer broke the protocol, and sending the same again fails the same way. A chunk handed straight to a waiting reader counts for nothing.macula_stream:start_link/1takes amax_inbox_bytesoption for another bound, andmacula_stream:info/1reportsinbox_bytes.The streams a node serves share a budget for chunks no reader has taken: one caller's streams together keep at most 16 MiB, and all served streams on the node at most 256 MiB, set with the
max_served_inbox_bytes_per_callerandmax_served_inbox_bytesmacula application env. The caller budget is one stream's own bound, so a caller with many sessions keeps no more unread than one session may. A chunk past a budget ends its session withresource_exhausted, as a chunk past the stream's own bound does. A stream charges the budget in its own process and gives the bytes back when a reader takes them or the stream ends.macula_stream_sessions:inbox_bytes/0reports what the node's served streams keep, and a refused charge counts ascaller_budgetornode_budget.A stream takes chunks only from the side its mode lets send: the server in
server_stream, the client inclient_stream, and both inbidi. A session whose peer sends a chunk from the side its mode keeps silent now ends withstream_protocol_error: the peer gets a STREAMERROR with that code, and the owner is told `{macula_stream, ended, Stream, {error, {<<"stream_protocol_error">>, }}}. A send the mode does not allow returns{error, {send_not_allowed, Mode}}` and sends nothing.A served stream is owned by the process that runs its handler, and a stream process ends when its owner ends. So a stream ends when its handler returns or crashes, and a stream served by
macula_streamerends with the streamer. A handler that lets another process keep using its stream must now hand the stream over before it returns:ok = macula_stream:controlling_process(Stream, Pid).macula_streamerstops when its stream's session ends, even when its module would not stop by itself.macula_identity:generate/0returns an identity that passes the station puzzle.macula_identity:generate/1does the same unless givenpuzzle => false, which returns a plain key.The S/Kademlia puzzle difficulty that
macula_identity:puzzle_valid/1applies, and thatmacula_identity:generate/0andgenerate/1grind to when nodifficultyis given, is themaculaapplication envpuzzle_difficulty, so asys.configentry formaculasets it. It stays 8 leading zero bits when unset, and a set value must be an integer from 0 to 16. A node configured with any other value, a difficulty above 16 included, does not boot: themaculaapplication refuses to start with{bad_config, {macula, puzzle_difficulty, Value}}. The same error is raised when the difficulty is used, for a value set while the node runs.macula_identity:check_puzzle_difficulty/0runs that check.
Removed
- The
mdnsanddhtcluster strategies and the discovery code behind them:macula_cluster_strategy,macula_dist_discoveryandmacula_dist_mdns_advertiser, with themacula_mdnsdependency and theoptional_applicationsentry formdns. The macula application never started this code. macula_frame:sign_swim_update/2andmacula_frame:verify_swim_update/1, with their private helpers and themacula-v2-swim-updatesigning domain. Nothing signed or verified SWIM membership updates. SWIM itself stays:macula_frame:swim_update/1and the piggyback updates in SWIM PING and ACK frames are unchanged. An update no longer has an optionalsignaturekey.- The Ed25519 CONNECT and HELLO frames of
macula_peering_conn, with therealms,verifyandpin_tls_certoptions. macula_record_uuid:v7/1. Record versions come fromv7_monotonic/1, andv7/0stays for ids that need no order.- The ADVERTISE and UNADVERTISE frames a station link sent.
macula_station_link:advertise/4,5,advertise_stream/5,6,unadvertise/3andunadvertise_stream/3register or remove a handler on the link, for the CALLs and STREAM_OPENs its station delivers to it by target, and send nothing, before or after the link connects. macula_client:call_stream/5, which opened a stream on the pool's first healthy link with no target. A stream open names its target, andmacula:call_stream/5resolves it.- The certificate-chain form of a provider authorization. The 11.0.0
realm issues no X.509 certificates, so a provider is authorized only by
the realm-signed org directory and the org-signed procedure delegation.
macula_record:verify_authorization/3refuses an authorization in any other form asauthorization_form_unsupported, andprocedure_advertisement/5builds only the delegation form.macula_record's trust takes onlyrealm_key. Therealm_catrust key, certificate path validation, and theno_realm_caandcert_*refusals are gone.
Fixed
The
macula_record:envelope/4documentation said a per-subject storage key is a BLAKE3 digest.macula_record:storage_key/1derives it with SHA-256, like every other derived storage key.macula_content_transfer:start_get/3andstart_get_station/5refuse an MCID that is not the SHA-384 id of a single block or of a manifest withfunction_clause, in the caller, as the module documents for input of another shape. Such an id used to start a transfer whose process then crashed.macula_quic:reset_stream/2records the reset before it resets the stream's send side, so asend/2or taggedasync_send/3write it interrupts ends with the reasonreset.macula_stream:await_reply/1,2called after a stream's session ended returns at once how it ended: the peer's abort as{error, {Code, Message}},{error, peer_closed}when the peer closed both sides, or{error, peer_down}when the peer ended. An ending that follows does not replace it.A
macula_station_linkstarted without anidentitygenerates one whose node id passesmacula_identity:puzzle_valid/1, as themacula_clientpool's default identity does.macula_client:unsubscribe/2takes a subscription off the wire. When the last local subscriber of a (realm, topic) leaves, the pool sends UNSUBSCRIBE on every station link that carried the SUBSCRIBE, including a link it respawned and replayed the subscription onto.macula_station_link:unsubscribe_async/2drops a subscription without waiting for the link.
[11.1.0] - 2026-09-16
Added
macula_record:read_foundation_realm_trust_list/1reads a verified foundation realm trust list back as a map of realm id to realm key id, andfoundation_realm_trust_list_key/1derives its storage key from a foundation key id, so a station fetches the list without holding its record (D28).macula_record:verify_authorization/3takes a trust'srealm_pairs, a map of realm id to realm key id, besides a pinned carriedrealm_key: the org directory's signer is then compared by key id against the pair the foundation realm trust list names for the advertisement's realm (D28).macula_quic:stop_stream/2stops a stream's receive side with a QUIC STOP_SENDING frame carrying an application error code. The peer's writes on the stream then fail with{error, {stopped, Code}}, its owner gets{quic, send_failed, Stream, {stopped, Code}}, and none of its later data reaches the stopped side, whose send side stays open.- Gossip discovery requires a shared secret of at least 32 bytes.
macula_cluster_gossip:start_link/1takes it as thesecretoption or inMACULA_GOSSIP_SECRET, and otherwise returnssecret_requiredorsecret_too_short;macula_cluster:start_cluster/0,1returns that as{error, {gossip_strategy_failed, Reason}}when it would start gossip. macula_direct_dialreadsdial_iofrom the options ofcall/6,call_stream/6andpublish_advertisement/5, and of the newget_content/4,fetch_content/5,put_content/5andresolve_station_endpoint/4: the DHT lookups, dials and transfers a call runs on, each function at the arity its key takes. A givendial_iohas every function the call runs on and may carry the others; any other is refused withfunction_clausein the caller. Without one, themaculaandmacula_content_transferfunctions are the defaults, so no caller changes.
Changed
macula_record:foundation_realm_trust_list/1,2now takes its entries as#{realm_id, realm_key_id}maps, and the record's payload holds exactlyrealms_trusted, an array of those maps, asDESIGN_PQ_SIGNED_FRAMES_AND_RECORDS.mdpins it: therealms_revoked,versionandvalid_untilfields and the old flat list of realm key ids are gone, and a payload outside the pinned shape verifies asmalformed.macula_clustersets no distribution cookie and reads or writes no cookie file.ensure_distributed/0starts distribution without setting a cookie, so a node has its release's cookie:-setcookie, or the owner-only.erlang.cookieOTP reads in the node'sHOMEand creates when it is missing.- DHT frames carry the D28 paging and acknowledgement fields: STORE_ACK
gains
signer(the record's key id) andrecord_version(the record's version), FIND_VALUE an optionalafter(a signer key id, to page a slot), and VALUE an optionalnext(the last entry's signer key id, present only when more follow). The wire names the STORE_ACK versionrecord_version, notversion: the base frame header already carries aversionfield. macula_store_pacerpaces record bytes on the DHT put paths, so a node that writes many records stays under a station's STORE byte allowance (D28, 3.5) instead of running intostored0: a caller-side bucket of 16 MiB per connection, refilled at 1 MiB per second, waited out in the calling process.macula_station_link:put_record/2,3andmacula:put_record/2pace through it; one bucket per pool is conservative for a pool of several links, since its calls fan out one link at a time.
Removed
- The
macula_console,macula_cert_system,macula_certandmacula_trust_storemodules, the certificate trust store with them, andinclude/macula_cert.hrl. 11.0.0 has no certificate form (design B1): a provider authorization is only the realm-signed org directory and the org-signed procedure delegation. TheAUTHORIZATION_GUIDE's certificate sections are replaced with a removal note. macula_mri:index_descendants/3,index_insert/4,index_remove/3,index_size/1andis_valid/1;macula_names:local_node_id/0;macula_source_route:version/1;macula_quic:accept_stream/3,async_shutdown_connection/3andhandoff_stream/3;macula_crypto_nif:blake3_streaming/1andblake3_verify/2;hecate_or_set:tombstones/1;macula_hyparview_view:contains/2;macula_cluster:get_cookie/0andset_cookie/1, andmacula:get_cookie/0andmacula:set_cookie/1with them: callerlang:get_cookie/0anderlang:set_cookie/1. bc-gitops'sbc_gitops_clustercalls themaculafunctions when macula is loaded, so with this release itsget_cookie/0, whichbc_gitops_vm_spawner:spawn_vm/4calls, raisesnot_distributedon a node that is not distributed. Upgrade bc-gitops to a release that no longer calls them before upgrading macula.- The cookie sources
macula_clusterresolved and the cookie file it kept: thecookieapplication env, theMACULA_COOKIE,RELEASE_COOKIEandERLANG_COOKIEenvironment variables, reading~/.erlang.cookie, and generating and saving a cookie there when that file was missing, withresolve_cookie/0,read_cookie_file/0andcookie_file_path/0, andentrypoint.sh. - No longer exported:
macula_mri:parent_type/1,macula_mri_registry:list_custom_types/0andmacula_dist_relay_protocol:decode/1. - The NIF stubs of
macula_cbor_nif,macula_crypto_nif,macula_did_nif,macula_mri_nifandmacula_ucan_nifthat only their own module calls are no longer exported; the wrapper functions of each module are the API. The stubs other modules call stay exported.
[10.25.0] - 2026-09-14
Every node should upgrade to this release.
Added
macula_frame:validate_received/1checks that a frame decoded from a peer's bytes carries every field itsframe_typerequires, each with a value that type's builder accepts, and returnsokor{error, {invalid_frame, Type, Field}}. A frame type this node does not know passes; a frame without aframe_typedoes not. Sample frames from the Go, Rust and .NET SDKs, and frames macula-station builds with macula 10.21.0 and 10.24.0, all pass; they are intest/fixtures/sdk_frames.macula_frame:parse_received/1drains the complete frames a peer sent from a buffer. It returns{ok, Items, Tail}, or{malformed, ItemsBefore, Reason}at the first frame that does not decode, whereReasonisframe_too_large, decided from the length header,bad_frame, ortoo_many_elementsfor a frame with more CBOR items than the decoder's element budget. An item is a frame, or{invalid_frame, Type, Field}for a frame that decodes but whose fieldsmacula_frame:validate_received/1refuses; parsing goes on after it. ATailkept for the next chunk never exceeds the frame cap plus its header.macula_frame:parse_received/2isparse_received/1with a frame cap of its own, up to the 16 MiB frame cap: a length header above it isframe_too_largefrom its four bytes, so aTailkept for the next chunk never exceeds that cap plus the header.macula_station_link:not_sent/1says whether an error fromcall/5,6means the CALL never went out: the link was not connected yet, there was no link process, or the link refused the frame before sending it.macula_quic:async_connect/4starts a dial and returns at once. The calling process receives{quic, connected, Tag, ConnRef}or{quic, connect_failed, Tag, Reason}, withTagfrommacula_quic:dial_tag/1.macula_quic:cancel_connect/1ends a dial and leaves no result for it in the caller's mailbox.scripts/is_quic_nif_built_from_this_tree.shchecks thatmacula_quicloads from a build and, given that build's log, that no precompiled NIF was fetched. The hex publish workflow runs it on the package it builds, before the publish is approved.macula_quic:listen/3takesstream_receive_windowandreceive_windowin bytes: the credit a peer gets per stream and per connection before this side reads. The defaults are 16 MiB and 64 MiB.macula_quic:async_send/2returns{error, busy}when a stream already has 1 MiB queued; the caller later gets{quic, send_ready, Stream, undefined}and may retry. A failed write is reported once to the stream's owner as{quic, send_failed, Stream, Reason}.macula_owner_only_file:write/2andread/1keep a file on disk where only its owner can read it.write/2fills a new file inside a private directory next to the target and renames it into place, so a symlink at the target is replaced rather than written through.read/1returns the content of a regular file its group and others have no access to, following symlinks. Otherwise it returns{error, {file_permissions, #{file, mode, required}}}or{error, {file_type, #{file, type, required}}}, naming the file, what it found and what is required.macula_quic:async_open_stream/1starts opening a stream and returns at once. The owner receives{quic, stream_opened, Tag, StreamRef}or{quic, stream_open_failed, Tag, Reason}, withTagfrommacula_quic:stream_open_tag/1.macula_quic:cancel_open_stream/1ends an open and leaves no result in the caller's mailbox.macula_peering:async_open_dedicated_stream/1returns a reference at once. The caller receives{macula_peering, dedicated_stream_opened, Ref, Stream}or{macula_peering, dedicated_stream_open_failed, Ref, Reason}.macula:find_record/3andmacula:find_records/3take a timeout for one DHT lookup, for a caller that bounds its work by a deadline of its own.macula:call_station/7acceptsdial_timeout_msin its options, passed to the newmacula_client:call_station/9: how much of the call's timeout the wait for a fresh link's handshake may take.{error, not_connected}then comes back after that time, before any CALL was sent.macula_direct_dial:fetch_content/4fetches an MCID from the first of its announced providers whose fetch succeeds, with a fetch function of the caller's own.macula_direct_dial:resolve_station_endpoint/3takes a timeout.macula_lifetime_announcerpublishes a supervised wrapper's lifetime facts from a process of its own:start/2starts it for the calling wrapper, andannounce_end/2hands it the end payload.macula_stream_sinkannounces through it.macula_stream_sink:start_link/7andstart_link_direct/7take start options.stream_iogives the functions a sink opens, reads and ends its stream with,call_stream/5,recv/2,close_stream/1andabort/3, andfact_publishthe function it announces its facts with. Without them they are themaculafacade's, and a direct-dial sink dials withmacula_direct_dial:call_stream/5.macula_stream:stream_io/2checks the stream functions a supervised stream wrapper is given against the ones it calls: each of those keys present, every function at the arity its key takes, and no key outsidemacula_stream:stream_io(). Without any it gives the wrapper's own. A set it refuses raisesfunction_clausein the caller.macula_subscriber:start_link/6takessubscribein its options: the function it subscribes with,macula:subscribe/5by default. The other options pass through to that function.macula_publisher:start_link/7takes options:publish, the function it publishes the payload with, andfact_publish, the function it announces its facts with, bothmacula:publish/4by default.macula_request:start_link/8takes options:call, the function it calls with,macula:call/5by default, andfact_publish, the function it announces its facts with,macula:publish/4by default.start_link_direct/8takesdirect_call,macula_direct_dial:call/6by default, andfact_publish, and passes its other options to the call.macula_response:advertise/6andadvertise_direct/7take three functions in their options:advertise, which advertises the handler,macula:advertise/5by default;publish_advertisement, whichadvertise_direct/7publishes its DHT record with,macula_direct_dial:publish_advertisement/5by default; andfact_publish, which each response announces its facts with,macula:publish/4by default. The other options go on to those functions without these three.macula_streamer:advertise/6andadvertise_direct/7take functions in their options:advertise_stream, which advertises the procedure,macula:advertise_stream/6by default;publish_advertisement, whichadvertise_direct/7publishes its DHT record with,macula_direct_dial:publish_advertisement/5by default;fact_publish, which each streamer announces its facts with,macula:publish/4by default; andstream_io, therecv/2,send/3,close_send/1,close/1,abort/3,set_reply/2andset_error/2each streamer runs its stream on, checked bymacula_stream:stream_io/2. The advertise function gets only theauthpolicy, and the advertisement publish the other options without these functions.macula_upload:advertise/6andadvertise_direct/7takefact_publish, which each upload announces itssharing.upload_*facts with,macula:publish/4by default, and passstream_ioandadvertise_stream, and foradvertise_direct/7alsopublish_advertisement, on tomacula_streamer.macula_pusher:start_link/7andstart_link_direct/7take start options.stream_iogives the functions a pusher opens, writes and aborts its stream with and awaits the reply through,call_stream/5,send/3,close_send/1,await_reply/1andabort/3, checked bymacula_stream:stream_io/2, andfact_publishthe function it announces its facts with. Without them they are themaculaandmacula_streamfunctions, and a direct-dial pusher opens withmacula_direct_dial:call_stream/5.macula_content_transfer's start functions takelink_ioin their options: thepick_connected_link/1,ensure_content_link/4,open_content_stream/1,call_on_stream/6,close_content_stream/2andabort_content_stream/4a transfer reaches its link with, themacula_clientandmacula_station_linkones by default. A set without one of them, with a function of another arity or with another key is refused withfunction_clause, in the caller.macula_feeder:start_link/6andstart_link_direct/7, andmacula_download:start_link/6andstart_link_direct/6, take start options:transfer_io, the functions they start, await and cancel their transfer with, checked by the newmacula_content_transfer:transfer_io/2(a feeder'sstart_put/3,start_put_station/5,await/1andcancel/1, a download'sstart_get/3,start_get_station/5,await/1andcancel/1);resolve_station_endpoint/2for a direct-dial feeder andfetch_content/4for a direct-dial download, themacula_direct_dialones by default;fact_publish; andlink_io, which they pass on to the transfer they start.
Changed
macula_cbor_nif:unpack_deterministic/1, the decoder behindmacula_frameandmacula_record, decodes at most 131,072 CBOR items from one input, an array, a map, a key and a value each counting as one, and raisestoo_many_elementsbeyond that. A header that claims more items than are left is refused before any of them is read. It runs on a dirty CPU scheduler. The same budget applies on every macula node. An SDK with a larger budget accepts frames that a macula node refuses.- A frame and the records in it share one element budget of 131,072 CBOR
items:
macula_framedecodes each record in arecordorrecordsfield within what the frame's own items and the records before it left. A STORE, REPLICATE or VALUE whose records need more is an invalid frame named by that field.macula_cbor_nif:unpack_deterministic/2decodes within what a caller has left and returns what is left after it,macula_cbor_nif:element_budget/0returns the budget, andmacula_record:decode/2decodes a record within what is left. macula_frame:parse_stream/1keeps its{Frames, Tail}shape and bounds what a caller keeps. Bytes that do not decode end the parse: the frames before them come back with an emptyTail, and the rest of the buffer is dropped.Framesholds only frames that passmacula_frame:validate_received/1; an invalid frame is dropped without a warning. It is deprecated, see Deprecated.macula_frame:decode/1checks every frame it decodes withmacula_frame:validate_received/1and no longer decodes a frame without that check. A frame whose fields are refused comes back as{error, {invalid_frame, Type, Field}}, and a frame withoutframe_typeas{error, {invalid_frame, unknown, frame_type}}, so a map withoutframe_typeno longer decodes. A frame whose own CBOR items exceed the element budget comes back as{error, too_many_elements}.macula_peering_connends the connection with the reason{malformed, Reason}when its handshake or control stream does not decode, or when a handshake frame is invalid. On the control stream an invalid frame is dropped, the frames after it are routed, and the controlling process receives{macula_peering, invalid_frame, Pid, Type, Field}. During the handshake it reads frames of up to 64 KiB: a length header above that ends the connection with{malformed, frame_too_large}as soon as the header arrives.macula_station_linkends a dedicated or content stream whose bytes do not decode as frames, or that carries a frame missing a field its type requires. On a dedicated stream the sessions it carries end; on a content stream the call waiting on it fails with{error, {malformed, Reason}}. The frames before it are still handled, and the link and its other streams carry on. A peering connection'sinvalid_framenotice changes nothing on the link.macula_dist_relay_client:close_tunnel/2sendstunnel_closeonly for a tunnel the client knows, active or still being set up, and ignores an unknown tunnel id.- A CALL frame the link refuses to send comes back from
macula_station_link:call/5,6as{error, {refused, Reason}}, where it was{error, Reason}. macula_dist_relay_client:request_tunnel/2returns{ok, Conn, Stream, Received}, whereReceivedholds the tunnel's bytes the client read before handing the stream over.- A procedure advertised with
{ucan_required, Issuer}, unary or streaming, requires the token's audience (aud) to be the calling identity's public key in lowercase hex, as{realm_member_required, RealmDid, RequiredCan}does. A token whose audience is another identity is refused withunauthorized. Mintucan_requiredtokens for the caller that will present them. macula_identity:load/1accepts only a key file its group and others have no access to, mode 0600 or 0400, following symlinks. Another mode returns{error, {file_permissions, #{file => Path, mode => <<"0644">>, required => <<"no access for group or others (0600 or 0400)">>}}}, and a path that is not a regular file returns{error, {file_type, ...}}. Before upgrading, set every identity key file to mode 0600. A caller that generates and saves a new key whenload/1fails should do so only on{error, enoent}: on any other error it would replace the node's identity.macula_identity:save/2writes throughmacula_owner_only_file:write/2, replacing a file or symlink at the path atomically, and creates a missing key directory with mode 0700.macula_quic:connect/4waits for its connection in the calling process, with the same arguments, results and timeout. A dial ends when the process that started it exits.- A client
macula_peering_connhandles close and reject while it is still dialing, and stops dialing when its controlling process exits. priv/build-nifs.shbuilds the QUIC NIF from this tree'snative/macula_quicsources, as a required crate likemacula_cbor_nif, intopriv/macula_quic.so. Building macula needs a Rust toolchain, as the CBOR NIF already did.- A QUIC stream's writes run in a writer task on the QUIC runtime.
async_send/2queues and returns at once.send/2waits in the calling process until its data is written or the write fails, for as long as the peer withholds flow-control credit. After a write fails, both return that error. close_stream/1andreset_stream/2return at once. A close writes the data queued before it, then finishes the stream. If that data cannot be written withinquic_close_linger_ms(default 30000), the stream is reset with application error code 1. A reset drops queued data, and asend/2waiting for it returns{error, reset}.- A
macula_peering_connwhose control stream write fails reportsdisconnectedwith that reason and stops.macula_station_linkends the streaming sessions on a dedicated stream whose write failed, and fails a content call waiting on such a stream.macula_dist_relay_clientends as on a closed control stream. macula_dist's QUIC controller sends withasync_send/2and leaves distribution data with the runtime while the stream is busy.macula_quic:open_stream/1waits in the calling process for as long as the peer allows no further stream, and the open ends when that process exits.macula_peering:open_dedicated_stream/1waits in the calling process and returns{error, timeout}after 10 s, or{error, closed}when the connection ends, instead of exiting. A peering connection keeps serving while one of its dedicated stream opens waits.macula_dist_relay_clientsends control frames without waiting on the relay. While the relay takes no data on the control stream, the client holds the frames in order and keeps serving tunnel requests, inbound tunnels andstatus/1, which reports them asheld_control_frames.- Direct dial (
macula_direct_dial:call/5,6andcall_stream/5,6) treats every advertisement that passes trust filtering as a candidate, in the order the DHT returns them. A candidate whosestation_endpointcan't be resolved, or whose link doesn't connect, is passed over for the next before anything is sent; once a CALL or a stream has gone out, its outcome stands. Resolution asks the DHT again after a pause that doubles from 100 ms to 1 s, and tries a candidate that failed again only when its advertisement or endpoint record has changed. The call'sTimeoutMs, or a stream'sdial_timeout_ms, bounds resolution, each candidate's connect wait and the request. At the deadline the result is the most recent candidate's failure; failing that, why an answered lookup found nothing qualifying; failing that, a failed lookup's own error; otherwise{error, {unresolved, timeout}}. A failed lookup is retried like an empty pass. macula_direct_dial:get_content/3tries the next announced provider after a fetch that fails or doesn't verify, and itsTimeoutMsbounds the whole fetch, transfers included.macula_download:start_link_direct/4,5chooses among providers the same way within 30 s, andcancel/1still reaches whichever transfer is running.macula_direct_dial:put_content/4'sTimeoutMsbounds the station endpoint lookup as well as the connect wait. A station endpoint lookup, for a put or for a direct-dial candidate, asks again past a failed lookup as well as an absent or expired record, and when none finds a usable record its result is, in this order, the endpoint not found, the failed lookup's own error, or{error, {unresolved, timeout}}.streaming.completed_v1frommacula_stream_sinkcarriesreasonas the reason's name, a binary such as<<"timeout">>of at most 64 bytes or else<<"crashed">>, and the message of the abort it sends is that name. The whole reason is logged locally.- A peer is told a reason's name and none of its terms in the message of
the STREAM_ERROR
macula_streamersends when its stream ends abnormally orhandle_open/2refuses it, in the message open streams are aborted with when their link disconnects, and in the message of the STREAM_ERROR a crashing stream handler's caller gets, over a link or locally. A crashing stream handler is logged on its node. - A handler's
{error, Reason}reaches its caller withReasonitself as the CALL_ERRORdetailwhen it is a binary or a printable Unicode charlist, judged by its first 257 elements, as at most 256 bytes of valid UTF-8 cut on a character boundary. Any other reason gives its name, such as<<"refused">>for{refused, Why}, or no detail when it has none. - The warning for a crashing handler, unary or streaming, and the log
lines of
macula_lifetime_announcerprint at most 4,096 characters of the terms they carry.
Deprecated
macula_frame:parse_stream/1is deprecated and removed in 11.0.0. Move tomacula_frame:parse_received/1, which reports invalid frames and bytes that do not decode instead of dropping them. The-deprecatedattribute follows in the first macula minor release after macula-station callsparse_received/1: added now, it would fail the xref check of projects that still callparse_stream/1and check for deprecated calls.macula_direct_dial:resolve_content_provider/2resolves through the same candidate loop, within 10 s, and is deprecated: it is removed in 11.0.0, andfetch_content/4replaces it.
Removed
- The precompiled QUIC NIF download:
priv/fetch-nif.sh,scripts/fetch-nif.sh, thebuild-nif.ymlworkflow that uploaded thelibmacula_quicrelease assets, andMACULA_FORCE_SOURCE_BUILD, which only skipped that download. Releases up to 10.24.0 keep their assets, so those versions still download them. Dockerfile,Dockerfile.gatewayand.dockerignore. They built for the earlier quicer transport, without the Rust NIFs, and could not build this repository.
Fixed
macula_manifest:from_wire/1returns{error, invalid_manifest}for a manifest that does not describe whole content: achunk_sizethat is not a positive integer, asizebelow 0, achunk_countother thanceil(size / chunk_size)or other than the number of chunks listed, a chunk whose index, offset or byte count is not the one its place requires, or a chunk hash or root hash that is not 32 bytes.get_content/2, the addressable get and the upload receiver refuse such a manifest before they fetch or count any chunk.macula_manifest:verify/2returns{error, invalid_manifest}for a chunk size that is not a positive integer, andmacula_manifest:create/2accepts only a positive integerchunk_size.macula_record:decode/1returns{error, bad_record}for bytes that are not CBOR and{error, too_many_elements}for bytes over the element budget ofmacula_cbor_nif:unpack_deterministic/1. A STORE, REPLICATE or VALUE whose record bytes do not decode as a record is an invalid frame named by itsrecordorrecordsfield.macula_dist_relay_clientdrops a tunnel and sendstunnel_closefor it once the process holding its stream ends: the caller ofrequest_tunnel/2for an outbound tunnel, and for an inbound tunnel its setup process until that names the dist controller, then the controller.macula_station_link:call/5,6no longer sends a CALL that the link reaches only after its caller's deadline, when the link was busy for longer than the call's timeout: its caller has already been told it timed out. A CALL frame carries the deadline its caller set, and the link waits for the reply until then, instead of counting the timeout from when it got to the call.- A
macula_clientsubscription to a topic with a*segment receives the events it matches. The pool looked subscribers up by the event's own topic, so such a subscription received none. An event matching several subscriptions, such asa/*/canda/b/c, reaches each subscriber once. macula_client:call/5tries another link only when the link reports that the CALL never went out. A CALL that timed out, or whose link dropped while it was pending, is no longer sent again on another link, where the provider could run it a second time.macula_quic:controlling_process/2returns only once no message for the handle is on its way to the former owner, so a stream's data and events, and a connection's new_stream notices, all go to the new owner from then on. A delivery already under way could reach the former owner after the call returned, so the process taking over a stream could miss bytes.macula_quic:close_listener/1delivers no{quic, new_conn, ...}after it returns, and closes a connection whose handshake completes after the close. A handshake that was completing could deliver new_conn to the owner of a listener already closed.- A dist connection over the dist relay keeps every byte its tunnel
stream delivers while
macula_dist_relay_clienthands the stream over. Bytes that arrive behind the tunnel id, or while the tunnel waits fortunnel_okortunnel_notify, reach the dist controller before anything else. The client dropped them, and the connection then waited until its setup time ran out. macula_cluster:start_cluster/1returns{error, {strategy_unavailable, dht}}for thedhtstrategy, and{error, {strategy_unavailable, mdns}}formdnswhen nomacula_dist_discoveryserver runs, without starting distribution. It started distribution and then failed with anoprocerror.macula_dist_discovery:start_link/1returns{error, {strategy_unavailable, dht}}fordiscovery_typedhtand forboth, the default. Such a server used to start and answer every registration and lookup from its own node only.macula_download:cancel/1andmacula_feeder:cancel/1cancel the underlying content transfer whenever it has started, however soon after the start the cancel lands. The download or feeder now starts the transfer itself, in a call from its worker.macula_dist_poolreads the reply and the arguments of the distribution tunnel RPC (_dist.tunnel.<node>) under the atom or{text, Name}key that a decoded frame payload holds. It read them with binary keys, which a decoded payload never has, so a tunnel request for distribution over the mesh pool (macula:join_mesh/1) ended in{error, {unexpected_result, _}}.- A build of a commit whose
native/macula_quicchanged after its version's release loads its own QUIC NIF. It installed that release's precompiled NIF instead, andmacula_quicfailed to load with{bad_lib, "Function not found macula_quic:nif_connect/8"}. - A stopped
macula_stream_sinkwaits for its reader to exit before it ends its stream, so the reader never callsrecv/2once the sink has gone. The sink sent its reader a kill and went on, and a kill arrives asynchronously. macula_stream_sinkpublishesstreaming.started_v1andstreaming.completed_v1from a process of its own. A publish that exited, for a pool that was gone, failed the sink's start with its stream left open or skippedhandle_close/2at its stop, and one that waited on a busy pool held the start or the stop for up to 5.5 s. A failed publish is now logged. A sink killed before its stop has itsstreaming.completed_v1published withoutcome => failedand the reason.macula_upload:advertise/6passes itsauthandreuse_supoptions, and its other options exceptfact_publish, on tomacula_streamer:advertise/6, asadvertise_direct/7does. Upgrade if you advertise uploads throughadvertise/6.
[10.24.0] - 2026-09-10
Added
macula:dist_relay_client/0returns{ok, Pid}for the dist relay client thatmacula:join_dist_relay/1started, or{error, not_joined}. The client has no reconnect: it exits with{relay_closed, Reason}when the relay closes the connection. Monitor the pid and calljoin_dist_relay/1again after it goes down.
Changed
macula_station_linknow verifies an inbound STREAM_OPEN's signature against itscallerbefore dispatching it. A STREAM_OPEN that does not verify runs no handler and gets nothing back on its stream.- Streaming procedures can carry an auth policy.
advertise_stream/6onmacula,macula_clientandmacula_station_link, and anauthopt onmacula_streamer:advertise/6andadvertise_direct/7, take the same policies asadvertise/5. A STREAM_OPEN the policy refuses gets a STREAM_ERROR with codeunauthorizedand runs no handler. The pool keeps the policy when it replays a stream advertisement onto a respawned link. The/5forms are unchanged and meanopen. call_stream/5takes aucan_tokenopt. STREAM_OPEN carries an optionalucan_tokenfield, present only when the caller gives a token.- A chunked content fetch uses the fetched manifest only when its MCID,
recomputed from the manifest's canonical fields, equals the requested
MCID. Otherwise the fetch ends with
{error, manifest_mcid_mismatch}before any chunk is requested.macula_manifest:verify_mcid/2performs the check. - The pool deduplicates an inbound EVENT on its
(realm, publisher, seq)only when its publisher signature verified. Any other EVENT is deduplicated on that triple together with a digest of its topic and payload, so identical copies arriving over several links are still delivered once. orderedandlatest_onlysubscriptions keep separate per-publisher ordering state for EVENTs whose publisher signature verified and for all others. Unsigned EVENTs are ordered among themselves, per publisher.- An upload receiver (
macula_upload) uses a pushed manifest only when its MCID, recomputed from the manifest's canonical fields, is the MCID the manifest names. Otherwise the upload ends with{error, {invalid_manifest, manifest_mcid_mismatch}}on both sides and nosharing.upload_started_v1fact is published, the same as for a manifest that does not decode. macula_manifest:from_wire/1reads a manifest whose field names arrive as text keys, as the frame decoder leaves them in a node that has not yet loadedmacula_manifest, and reads a name or hash algorithm sent as text. A manifest whose chunks are not a list of maps, or whose hash algorithm is present but not blake3 or sha256, is{error, invalid_manifest}; a missing hash algorithm is still blake3.macula_manifest:verify_mcid/2returns{error, manifest_mcid_mismatch}for an unknown hash algorithm.- The Clustering Guide, the cluster and dist READMEs and the
macula_clusterdocumentation describe themdnsanddhtstrategies as not available, and namegossipandstaticinstead. They need a discovery service the application does not start.
Removed
macula_dist_system, withstart_link/0,1andstart_dist_relay_client/2. The application never started it, and it could not start next tomacula_root, since both startmacula_dist_bridge_supunder that registered name. Usemacula:join_dist_relay/1to start the dist relay client.- The
dist_relay_urlapplication environment key. Onlymacula_dist_systemread it.
Fixed
macula:join_dist_relay/1works on a running application. It exited withnoproc, because it started the relay client under a supervisor that the application never started. The client now runs as a temporary child ofmacula_root: a failed start returns{error, Reason}, and a client that ends is not restarted. Without the macula application running it returns{error, macula_not_started}.macula_dist_relay_clientnow exits with{relay_closed, Reason}when its control stream to the relay ends. The QUIC NIF reports a lost connection as a closed stream, which the client ignored, so after a relay loss it kept running and registered while distribution over the relay no longer worked.orderedsubscriptions hold a new publisher's first facts, and a publisher's first facts after a seq restart, for up to oneorder_timeout_ms(or untilorder_max_bufferfacts are held), and start that publisher's order at the lowest seq held. A lower seq that arrives after a higher one in that window is delivered, in order. A publisher's first facts arrive up to one order timeout later.latest_onlyis unchanged.
[10.23.0] - 2026-09-10
Changed
pubsub_strict_publisher_signow defaults totrue. An inbound EVENT whosepublisher_sigis present but does not verify is dropped by default. Set the option tofalseto keep delivering such events withpublisher_verified => falseinMeta. EVENTs that carry nopublisher_sigare unaffected and are still delivered asnot_signed.macula_station_linknow verifies the signature of every inbound CALL, RESULT and ERROR frame against the identity the frame names as its signer:calleron a CALL,responded_byon a RESULT,reported_byon an ERROR. A CALL that does not verify is not handed to its handler and gets no reply. A RESULT or ERROR that does not verify is dropped and leaves its call pending.- The inner frame of an
overlay_relayis now delivered to overlay subscribers only when its signature verifies against the origin the envelope names. Callers ofsend_overlay_frame/3must sign the inner frame with the identity of the link they send it on; the HyParView framesmacula_hyparview_protobuilds already are. macula_tls:quic_client_opts/0,1now return[{verify, webpki}]unless development mode is set explicitly. OnlyMACULA_TLS_MODE=development(ordev), or thetls_modeapp env set todevelopment, returns[{verify, none}]. Production returns[{verify, webpki}]alone, without thecacertfile,depth,certfileandkeyfileoptions the QUIC NIF never read. The QUIC NIF checks the server certificate against its built-in webpki roots and the dialed host. This affects the dist relay client and direct dist dials.- A CA file set through
MACULA_TLS_CACERTFILEor thetls_cacertfileapp env now makesmacula_tls:quic_client_opts/0,1raise{tls_config_error, {cacertfile_not_supported, Path}}, because the QUIC NIF cannot load one. macula_tls:quic_client_opts_with_hostname/1returns the same options asquic_client_opts/0. Listener options are unchanged.
[10.22.0] - 2026-09-07
Added
macula_peering_connnow tellscontrolling_pidwhen a graceful drain begins, not just when it concludes: entering draining for a graceful reason (either side'scast, {close, Reason}, or the peer's own control-stream shutdown — see Fixed, below) sends{macula_peering, draining, self(), Reason}. Acontrolling_pidthat already tracks its own dedicated/bidi stream count for this connection (e.g.macula-station'speer_observer) can now end the drain immediately once that count reaches zero (cast, dedicated_streams_idle) instead of always waiting out the fullDRAIN_TIMEOUT_MS(5s) — measured at ~0.01s versus ~5s for an otherwise-identicalclose/2starting point. Purely additive and backward compatible: acontrolling_pidthat does neither sees no behavior change, and thestate_timeoutbackstop is unchanged for it. No wire-level protocol change — this only decides when a LOCAL side is willing to close a connection it already holds silently; the peer sees nothing different until the eventualCONNECTION_CLOSEframe.macula-io/macula#9, part 2.
Fixed
- A peer's own graceful shutdown of the control stream
(
peer_send_shutdown) was treated identically to an abrupt stream-level error (stream_closed): an immediate{stop, normal, Data}, closing the whole connection with no chance for an in-flight dedicated/bidi stream on it to finish. This was asymmetric with the graceful path already used for a LOCAL close (cast, {close, Reason}, which sendsGOODBYEand drains for up toDRAIN_TIMEOUT_MSfirst) — a peer's graceful shutdown is the same signal in the other direction (confirmed againstmacula-go's ownSession.Close, which sendsGOODBYEthen closes just its control stream before the connection), not an error.peer_send_shutdownnow drains instead of stopping, matching the initiator path exactly; a genuinestream_closederror/reset is unchanged (immediate stop — draining a connection whose control stream just errored has no clear benefit). Scoped narrowly per adversarial review ofmacula-io/macula#9as the "cheap interim" mechanical fix; whether a dedicated stream's lifecycle should ever be coupled to the control stream's at all stays open.macula-io/macula#9, part 1. macula_station_link's two other disconnect paths ({macula_peering, disconnected, Pid, Reason}and{'EXIT', Pid, Reason}from the peering worker) now emit_macula.station_link.disconnected/_macula.station_link.peering_exitdiagnostics events carrying the realReason, matching the existingconnect_watchdogclause's own pattern. Both previously computed a real reason (peer-closed detail, drain outcome, a crash) purely to discard it before{stop, normal, ...}— every caller downstream (macula_client:on_down_routed/5, and therefore any consuming service's own logs) only ever saw a uniformreason => normalregardless of why the link actually went down. Found while investigating a live, ongoing hecate-sentinel reconnect loop against one specific station where this gap made the cause genuinely undiagnosable from client-side logs alone.
[10.21.0] - 2026-09-05
Security
macula_ucan_nif's opts-JSON mint path could silently produce a UCAN token that never expires. Two compounding gaps: the Erlang opts encoder (encode_json_term/1) escaped only", so anyfct/nncvalue containing a raw backslash or control character (a newline, say) produced syntactically invalid JSON; the Rust NIF'snif_createthen silently substituted an empty object whenever that JSON failed to parse, instead of erroring the way itscapabilities_jsonargument two lines above already does. Together: a caller that setexpgot back a token with noexpat all — no error, no signal, a token that never expires — for any UCAN this library has ever issued, not specific to any one policy or consumer. Found empirically (direct NIF probes) while adversarially reviewing the newrealm_member_requiredpolicy below, independently verified, and fixed on its own: the encoder now escapes the full JSON escape set properly, the NIF fails closed withmalformed_jsonon an opts parse failure, and the matching JSON-string decoder gap that fixing the encoder alone would have newly exposed (it only ever unescaped\"/\\, so\n/\uXXXXetc. would have round-tripped as the literal escape text rather than the real byte) is fixed to match. RED-checked both halves independently by reverting each in turn and confirming the original failure reproduces.
Added
{realm_member_required, RealmDid, RequiredCan}, a third per-procedureadvertiseauth policy alongsideopenand{ucan_required, Issuer}: gates a procedure on membership in a realm at a specific capability tier, rather than one exact known identity. A caller is authorized if it presents a UCAN signed byRealmDid(a realm's own DID keypair — distinct from the 32-byteRealmIdrouting/scoping hash used elsewhere) whoseaudis the wire- authenticated caller itself and whosecaplist carries the requiredcanstring. The audience check closes the ordinary UCAN bearer gap for this policy specifically (macula_ucan_nif:verify/2checks signature/expiry only, neveraud); the capability check closes a second, sharper gap found via adversarial review before this policy shipped as a final design: a realm mints membership UCANs at more than one tier from the same signing key (e.g. a human-confirmed citizen tier versus a self-service device tier gated only by proof of key possession), and signature-plus-audience alone cannot tell them apart — any device could otherwise self-enroll and pass identically to a genuine, human-admitted member.RequiredCanis mandatory, not optional-with-a-default, precisely so a caller must name the tier it actually needs rather than inherit a guess. RED/GREEN-verified (including the audience-bearer-replay case and the device-tier- bypass case, each independently RED-checked) and live-verified twice over the real production mesh.advertise/5now validates each auth policy's own shape (open|{ucan_required, Issuer}|{realm_member_required, RealmDid, RequiredCan}) with its own guarded function clause instead of one generic clause. A malformedIssuer/RealmDidnow raises immediately at the calling process, rather than being accepted at advertise time and only crashing later inside the link's own process loop on the first inbound CALL that exercises the policy — which would have faulted every other procedure multiplexed on that same link, not just the misconfigured one.
[10.20.3] - 2026-09-05
Fixed
priv/build-nifs.shandpriv/fetch-nif.shcould silently serve a stale compiled NIF. Both scripts skipped rebuilding purely on[ -f "$NIF_FILE" ]— whether the artifact existed at all, never whether it was still current. This is exactly what masked several of today's earlier verification runs: editingdeterministic.rsand runningrebar3 compile/eunitkept silently loading an Aug-27.sothrough a full day of "clean" results, until an unrelated regression test's crash (from decoding against genuinely stale code) surfaced it. Fixed by also checking, viafind -newer(POSIX, portable), whether any.rs/Cargo.toml/Cargo.lockfile under the crate is newer than the built artifact — rebuild if so.fetch-nif.shhad a second, related bug fixed in the same pass:MACULA_FORCE_SOURCE_BUILD=1did nothing at all whenever a cached artifact already existed, because the bare existence check ran before that env var was ever consulted.Verified RED-then-GREEN, not just read: reproduced the original silent-staleness bug in isolation (an aged
.sonext to a newer source file, old check says skip), confirmed the fix catches it (new check says rebuild), confirmed it doesn't over-trigger once genuinely up to date, and confirmed end-to-end against this repo's own build — touchingdeterministic.rsand runningrebar3 compilenow visibly logs[macula_cbor_nif] Building NIF from source...and produces a new.so, where before it silently did neither. Same defect (priv/ build-nifs.shonly — this repo has nofetch-nif.shequivalent) found and fixed the same way inreckon-db-org/reckon-db.
[10.20.2] - 2026-09-05
Documentation
- Documented a known, deliberately-unresolved gap found while auditing
content-transfer code adjacent to today's CBOR work:
chunk_mcid/3'sAlgorithmargument is unused, and per-chunk fetch verification (macula_content_transfer:verify_block_hash/2) is hardcoded to blake3 regardless of a manifest'shash_algorithmfield — so a manifest created withhash_algorithm => sha256produces chunk MCIDs that can never actually verify on fetch.hash_algorithmtoday only affects the manifest's own root-hash Merkle step. Same gap independently confirmed in macula-io/macula-rust. No behavior change in this release — recorded so it isn't silently rediscovered; whether per-chunk sha256 verification was ever meant to work is an open design question, not a bug with an obvious fix, and nothing exercises sha256 in practice today.
[10.20.1] - 2026-09-05
Fixed
Three pre-auth vulnerabilities in
macula_cbor_nif's deterministic decoder —deterministic.rs'sdecode_one/decode_array/decode_map, the codec every network-received frame is decoded through before any signature check (macula_frame.erl,macula_record.erl), running as a NIF directly on a BEAM scheduler thread. Found across four rounds of adversarial review of the same function, each round catching something the previous one missed:- O(n²) decode-time DoS (10.20.0's original fix):
decode_map's duplicate-key dedup was a linearTerm-equality scan over every entry already decoded. A map with many distinct keys, well under the 16MB frame cap, could peg a scheduler for tens of seconds, scaling quadratically toward the cap. - Uncatchable process crash via stack overflow: plain recursive
descent had no nesting-depth limit. A list-of-one-list-of-one-
list... encodes extreme nesting in one byte per level; a ~10KB
frame reliably segfaulted the whole BEAM VM (not just one
connection). Fixed with
MAX_NESTING_DEPTH = 128. - O(depth × key size) blowup reintroduced by fix #1: re-encoding
a key fresh via
encode_valueat every ancestor level, for a map whose key is itself a large nested structure, turned back into real seconds of pre-auth CPU on a frame still under the size cap. Fixed by threading aneed_canonflag alongside depth and building each decoded value's own canonical bytes bottom-up (computed once, only ever concatenated/sorted by ancestors, never re-derived) — bounding the cost to the sameMAX_NESTING_DEPTHbound already enforced elsewhere. - Uncatchable VM abort on a NaN/Infinity map key:
decode_major7's float32/float64 arms built an Erlang float term directly from the wire bits with no finiteness check, unlike the adjacent half-float arm. BEAM has no NaN/Infinity float representation; the resulting invalid term worked fine right up until fix #1/#3'sencode_valuecall inspected it as a map key, aborting the whole VM with an ERTS assertion failure (tag_val_def(), SIGABRT) — not a catchable Erlang exception. Fixed by rejecting non-finite floats in both arms, matching the half-float arm's existing behavior.
All four verified empirically (RED then GREEN against isolated pre-fix builds, not just reasoned about) and adversarially reviewed; 6 new regression tests added to
test/macula_cbor_deterministic_diff_tests.erl(nesting-depth boundary, extreme-nesting-without-crashing, duplicate-key-slot preservation — flat and nested-map-key cases, both key-count and key-size quadratic-complexity guards, 6 new NaN/Infinity malformed- input vectors). Full differential fuzz suite (3000 randomized terms against the pure-Erlang reference codec) and fullrebar3 eunit/dialyzerclean throughout.Also fixed a build-tooling gap this work exposed:
priv/*.sofiles undernative/*/priv/build-nifs.sh's "skip if already built" caching can go stale relative to source changes with no warning — this silently invalidated several "cleanrebar3 eunit" verification runs earlier today until caught by comparing against isolated scratch builds. Not fixed in this release (tracked separately); if verifying a change to any native NIF crate in this repo, delete the relevantpriv/*.sobefore trusting arebar3 eunit/dialyzerresult.- O(n²) decode-time DoS (10.20.0's original fix):
[10.20.0] - 2026-09-05
Changed
gprocbumped0.9.1 → 1.3.0and loosened to~> 1.3(was pinned exact). Crosses a major version boundary, so this was audited commit-by-commit across all four intervening tag jumps (16 commits total via the GitHub compare API) rather than taken on trust: every change is either purely additive (newreg_remote/unreg_remote/set_value_remote/update_counter_remoteremote-registration API, added in the 0.9.1→1.0.0 jump) or an internal, non-exported refactor (gproc_pool'ssetup_wait/do_wait/clear_waittimer-to-monotonic- time rewrite). The final 1.2.0→1.3.0 jump is "Address warnings for OTP 29." No exported function was removed or changed signature. This library only callsgproc:lookup_pids/1(in a standalone script and a test helper), which none of the above touches.telemetryconstraint loosened1.3.0 → ~> 1.3(was pinned exact). Two-segment~>still resolves to the real latest (1.4.2) in an isolated build, but — caught by adversarial review —~> 1.4would have made this unsatisfiable for anyone resolving macula alongside evoq/reckon_db/reckon_gater, which all pin telemetry exactly to1.3.0.~> 1.3is compatible with both.macula_mdnsfloor raised0.1.0 → ~> 0.1.1. 0.1.0 pinnedgprocexactly to0.9.1, which — also caught by adversarial review — made this repo's owngproc ~> 1.3unsatisfiable for any consumer resolving both packages together (rebar3 masked this locally by letting the root project's constraint win with just a warning; a real solver would fail outright). macula_mdns 0.1.1 fixes that pin and, incidentally, a real bug:mdns:vsn/0referenced the pre-rename OTP application name and crash-looped the built-in mDNS advertiser on every distributed node since the 0.1.0 rename. See macula-mdns's own CHANGELOG for the full trace.
Fixed
- Phantom
msgpackruntime dependency on every hex release since v3.0.0.rebar.config'sdepslist has had zeromsgpackentries since the v3.0.0 CBOR migration, but every published package (rebar3 hex publishderives its declared requirements from the resolved/locked dependency graph, not purely fromrebar.config) kept declaringmsgpack ~>0.8.1as a non-optional requirement — because a local, untrackedrebar.lockwas never regenerated after that migration, so it kept resolving and republishing the same phantom entry. Confirmed viarebar3 tree, not by reading lock file content. The only thing anywhere in source still requiring the actual msgpack library (as opposed to the unrelatedmsgpackatom used elsewhere as a symbolicmacula_streamencoding tag) wastest/dht_address_serialization_test.erl— a debugging-era test that doesn't call any macula function; it only exercises the third-party msgpack library's own tuple-vs-binary packing behavior, disconnected from the current CBOR wire path. Deleted that test and regenerated the lock;rebar3 treenow shows zero msgpack anywhere in the tree. Every consumer resolving this package from hex now gets one less unnecessary transitive dependency.
[10.19.2] - 2026-09-05
Fixed
- Removed
erl_opts(no_debug_info,deterministic) from theprodprofile inrebar.config. Confirmed live with a controlled A/B rebuild (a bare git dependency fetch of this exact repo, only variable changed was this profile'serl_opts): rebar3 merges a fetched dependency'serl_optsacross every profile that dependency defines, not just whichever one the consuming project actually activated. That meant this library'sprodprofile — a local-dev-only convenience for running a leaner standalone macula node, not used anywhere in this repo's own CI/scripts — was silently strippingdebug_infofrom every consumer's copy of macula, whether or not they ever selectedprodthemselves. Effect:rebar3 dialyzerwith macula inplt_extra_appsfailed for every consumer with "Could not get Core Erlang code," found independently by two sessions on three separate repos before being traced to this. Theprodprofile'srelxsettings (mode/dev_mode/ include_src) are untouched — only theerl_optsoverride is gone; pass--erl_opts +no_debug_infoon therebar3 as prod releasecommand line instead if that leaner local build is ever needed again.
[10.19.1] - 2026-09-05
Fixed
- Replaced 12 uses of the deprecated bare
catch Exprprefix-operator form (catch macula_stream:abort(...), etc.) withtry Expr catch _:_ -> ok end, acrossmacula_pusher.erl,macula_feeder.erl,macula_content_transfer.erl,macula_download.erl, andclient/macula_station_link.erl. OTP 29 deprecates this syntax; under a consumer's ownwarnings_as_errors(a common convention across this org's repos) it becomes a hard compile failure, not a warning, so this repo's own OTP-29-clean commitment doesn't help a consumer building with a newer default toolchain than this repo pins. Found live 2026-09-05 by two independent sessions bumping separate downstream repos to 10.19.0 under OTP 29. Behavior unchanged — every site was already a deliberate "best-effort, swallow any exception" idiom; thetry/catchform preserves that exactly. Predates 10.19.0's own changes (oldest site: 2026-08-21); unrelated to thereplication_factorwork, released separately since 10.19.0 was already tagged and published.
[10.19.0] - 2026-09-05
Changed
macula_client'sreplication_factordefault is now2, not1(only matters for a pool with 2+ connected links — a single-seed pool is unaffected). Publish is fanned toreplication_factorcurrently-connected links, and "connected" only means the app-liveness ping still answers — it has no way to know a link's station is silently relaying nowhere for some other reason (e.g. that station just doesn't serve/route the caller's realm). At the old default that single link was the entire story for every publish through the pool: total, silent data loss, withokreturned throughout, since a partial (here: complete) success still satisfiespublish/5's "first link to accept wins" contract.2is the minimum default that survives exactly that (one bad selected link no longer means zero delivery, as long as a second is live) without defaulting every publisher in the ecosystem to 3x baseline traffic for marginal extra protection past "survives one bad station" — callers with a reason to want the old single-link behavior (e.g. an already cost-conscious high-frequency publisher) can still passreplication_factor => 1explicitly.Does not protect against a wrong
Realmpassed by the caller — every replicated copy carries the identicalRealmargument, so a publisher-side realm misconfiguration blackholes every selected link the same way regardless of factor. The gap this closes is the adjacent, genuinely link-local case: caller config correct, one specific station's relay path silently broken. (Found live 2026-09-05 investigating a warden whose presence heartbeat never reached the mesh despite a healthy-looking connection — its own publisher turned out to be configured with a stale realm id, which replication would not have fixed; tracing that incident is what surfaced this adjacent gap.)status/1now also reports the resolvedreplication_factorso a caller (or a test) can confirm what the pool actually applied, rather than trusting a doc claim. The selection math itself (select_publish_targets/2) is now a pure function with direct unit coverage instead of being inlined inhandle_call({publish, ...}).
Fixed
- Publish's fan-out worker ran each selected link's
publish/5call unguarded in a plain list comprehension. A crash or exit from one link (a dead pid, a wedged connection hitting its 5s call timeout) skipped straight pastgen_server:reply/2, so the caller got a hard timeout instead of theokit should have received if an EARLIER link in the list had already accepted the frame — silently violating publish's own "partial success counts as success" contract. This exact ordering (success on link 1, failure on a later link) was structurally impossible at the old defaultreplication_factor=1(never more than one link to fail "after"); raising the default to 2 above makes it a real, common-path risk for the first time, so it's fixed in the same release.advertise's own fan-out already guarded each per-link call this way (safe_link_advertise/5); publish's fan-out now does too, via the equivalentsafe_link_publish/5.
[10.18.0] - 2026-09-02
Fixed
macula_station_linkran every inbound CALL handler inside the link process itself, so the link could not read its own peering connection while a handler was running. A handler that touched the mesh through the pool, publishing a fact or making a call of its own, then waited on a reply that had to arrive over the very link it was blocking, until its own timeout fired: the pool'sadvertiseandpublishcalls into that link timed out at 5 s, an outboundcallat its full deadline.macula_responsehas publishedrpc.received_v1on every request since 9.2.0, so every hecate-om desk was exposed; the ones that make a mesh call inside the handler failed outright. Found live 2026-09-02 on hecate-rag: every semantic search waited 30 s on itsio.hecate.embedcall and crashed, the advertise republish for its other capabilities timed out meanwhile, and the service flickered out of the station's registry, so callers sawunknown_next_peerfor a service that was up. Handlers now run in a process spawned per CALL, which is what theadvertise/4doc had promised all along, and the RESULT or call_error frame is sent from there. Inbound calls on one link are therefore served concurrently rather than one at a time, and a slow handler no longer delays other calls, subscriptions, advertises or publishes on that link. Two new tests: a handler that calls back into its own link gets a RESULT instead of a 1 s timeout, and a fast call injected behind a 1.5 s one is answered at once. Both failed on the previous code.
[10.17.0] - 2026-09-02
Fixed
- A station restart silently blinded every
ordered(the default) andlatest_onlysubscriber to that station's own facts until the station's publish counter climbed back over its pre-restart value. Two halves, both fixed here:hecate_pubsub_serverseeded itsnext_seqfrom 0 at start. A macula-station publishes its own facts (_dht.records.<type>.stored,_mesh.*) through this server under the station's persistent identity, so every restart rewound that publisher's seq.macula_clienthad always seeded its ownpublish_seqfrom wall-clock microseconds for exactly this reason; the server now does the same (erlang:system_time(microsecond)), so a restart is a large FORWARD jump thatmacula_pubsub_order-- on every already-deployed SDK version too -- reads as a new epoch.macula_pubsub_orderonly recognised a large forward jump as a publisher restart; a large BACKWARD jump fell through to the "already delivered, drop" clause, leaving the per-publisher watermark, buffer and skip counter untouched, so nothing in the pool state showed anything wrong. A backward jump wider thanEPOCH_JUMPis now treated as a restart in bothordered(the old epoch's buffered tail is released first, then rebase) andlatest_only(new high-water mark). A backstep within the threshold is still a late duplicate and is still dropped. Found live 2026-09-02:hecate-stations' read model stopped ingesting at the exact minute of a fleet-wide station rollout and stayed frozen for 10+ hours while its link, wire subscriptions, dedup table and subscriber processes all looked healthy -- a throwaway subscription on the same pool received 27 facts in 25 s with seqs around 249k while the standing one expected the next seq after 589k.hecate_stations.list_stationsreported 2 of 8 stations as a result, and the same mechanism had blinded it after every earlier rollout for as long as the previous epoch had lasted. Four new tests cover both halves; all four failed on the previous code.
[10.16.0] - 2026-09-01
Added
macula_pubsub:subscribe/4,5's deliveredMetamap now carriespublisher_verified(not_signed|true|false), alongside the existingrealm/publisher/seq/delivered_via. The verification outcome (whether an inbound EVENT'spublisher_sigchecked out) was already computed inmacula_station_link:on_inbound_event/5before this --check_publisher_sig/1callsmacula_frame:verify_publisher/1and branches onok/{ok, _}/{error, _}-- but that result was discarded beforedeliver_event/4(now/5) builtMeta, so a subscriber could seepublisher(the identity) but never learn whether its signature was actually valid, indistinguishable from "never signed." A lenient-mode delivery of an invalid signature (the default, so a relay bug surfaces rather than silently drops events) now reaches the subscriber taggedfalse, not folded into the same bucket as a genuinely unsigned event.
[10.15.0] - 2026-09-01
Added
- The inbound CALL frame's
callerfield (required, wire-authenticated — seemacula_frame's CALL spec) is now merged intoPayloadbefore an RPC handler runs, inmacula_station_link:handle_inbound_call/2. Until now this field was decoded off the wire and then silently dropped: it never reachedModule:handle_request/2(the callback every provider,macula_responseincluded, calls with justPayloadandState). Pub/sub already had the equivalent (publisherreaches asubscribe_callback/4handler viaMeta); this closes the same gap for the request/reply RPC path. - Deliberately not a
handle_request/2arity change — that callback is a fixed contract every existing provider implements, and bumping it would break every one of them.calleris merged into the payload map instead (Payload#{caller => Caller}), so a handler that wants provenance reads it exactly like any other field (hecate_om_wire:field(caller, Payload)), and one that doesn't needs no change at all. The merge happens after the payload is fully decoded, so it deterministically overwrites any same-named key a caller's own payload might supply — the value a handler reads is always the wire- authenticated identity, never spoofable via the payload body.
[10.14.5] - 2026-09-01
Fixed
macula_response/macula_streamer'sexisting_or_new_sup/1reused areuse_suppid unconditionally, without checking it was still alive. A caller that periodically re-advertises withreuse_sup(the documented pattern for keeping one factory supervisor across ticks instead of leaking one per tick) can find that pid already dead -- e.g. the caller itself crashed between ticks and, being linked to the factory sup it started viastart_link, took it down too. Reusing the dead pid handeddispatch/7(ordispatch/8) aSup' that wouldnoprocon its very firstsupervisor:startchild, silently breaking every inbound call for that procedure until a later re-advertise happened to land. Found live 2026-09-01 via hecate-rag:hecate_om_capabilitiescrashed on a timed-out advertise call and, through exactly this path, several unrelated capabilities (search_chunks_semantic/answer_query/add_knowledge) started failing every inbound call withnoprocfor the next few minutes.existing_or_new_sup/1now checkserlang:is_process_alive/1before reusing a pid and falls through to starting a fresh supervisor otherwise -- a pattern-matched predicate dispatch, not a try/catch. ## [10.14.4] - 2026-08-31 ### Fixed -macula_direct_dial:discovery_uri/2usedbinary:encode_hex(Realm)(lowercase) while the live fleet's DHTprocedure_advertisementrecords carry uppercase hex inprocedure_uri. SinceSHA-256(uppercase) != SHA-256(lowercase), the Go/Rust/.NET direct-dial resolvers were looking up the wrong DHT key — every direct-dial call failed with "procedure has no direct-dial advertisement". Changed tobinary:encode_hex(Realm, uppercase)so the source matches what the fleet actually publishes. ## [10.14.3] - 2026-08-31 ### Fixed -priv/build-nifs.shsilently skipped buildingmacula_cbor_nifwhencargowasn't on PATH -- a warning, not a failure, and the script still exited 0 ("All NIFs ready.") having built nothing.macula_cbor_nif.erl's own moduledoc is explicit that this NIF has no Erlang fallback and "failing fast at NIF-load time is the right behavior" -- the build script did the opposite: a clean, green build that fails every caller at runtime with an opaquenif_not_loadedinstead. Found live in a downstream consumer's CI (anerlang:28container with no Rust toolchain installed):rebar3 compileand dependency resolution both went green, then every test touching CBOR pack/unpack failed.cargomissing (or a build that reports success but produces no.so) is now a hard failure (exit 1) formacula_cbor_nifspecifically, with a message naming why. The other four Rust NIFsbuild-nifs.shbuilds (macula_crypto_nif/macula_ucan_nif/macula_did_nif/macula_mri_nif) keep the existing soft-skip -- each documents a real Erlang fallback in its own moduledoc, so a consumer without Rust still gets a working, if slower, build for those. Verified directly (isolated thebuild_niffunction, three scenarios): cargo present buildsmacula_cbor_nifnormally same as before; cargo absent now exits 1 with the new message; the four fallback-having NIFs still soft-skip and return 0 unchanged. ## [10.14.2] - 2026-08-30 ### Fixed -macula_station_link:maybe_send_subscribe/3gated a SUBSCRIBE frame's immediate send onpeer_pidalone, unlike its two siblings (maybe_send_advertise/3/maybe_send_unadvertise/3in the same module), which correctly gate onpeer_node_id.peer_pidis set the momentmacula_peering:connect/1returns, before the CONNECT/HELLO handshake completes;peer_node_idis only set once it genuinely has. A SUBSCRIBE frame sent in that window landed on the wire while the peering statem was still inhandshaking, which has no clause forcast({send_frame, })and silently drops it viadropunexpected(logged as_macula.peering.unexpected_event). Found live: a real deployment's logs showed this exact frame (topic_dht.records.N.stored) dropped on every reconnect. Harmless for a *stored* subscription —drain_pending_subscribes/1resends it onceconnectedgenuinely fires — but wasteful and alarming on every reconnect, and not harmless for a caller that assumed the frame had actually gone out. Regression test added (subscribe_during_handshake_not_sent_early_test); confirmed it fails without the fix before confirming it passes with it. ## [10.14.1] - 2026-08-29 ### Fixed - Bumpedrand0.8.5 → 0.8.6 innative/maculaucan_nif/Cargo.lockandnative/macula_crypto_nif/Cargo.lock(Cargo.toml already permitted it — lockfile-only update). Closes GHSA-cq8v-f236-94qc, the two remaining Dependabot alerts on this repo (both low severity: the unsound path needs a customloglogger readingrand::rng()from inside the log call itself, which neither NIF crate does — no working exploit here, fixed anyway since a patched version was available with no other changes needed).native/macula_quic/Cargo.lockwas already clear (rand 0.10.2). ## [10.14.0] - 2026-08-29 ### Added -hecate_pubsub:patterns/1andhecate_pubsub_server:patterns/1expose a subscriber's registered wildcard patterns (e.g.<<"/svc.do">>) as their own list, separate fromtopics/1. This is the export macula-station's bloom-exchange gossip now reads to propagate wildcard-pattern interest mesh-wide, rather than the station-local-only matching that existed before. ## [10.13.2] - 2026-08-29 ### Fixed -macula_record:read_node_record/1silently dropped theversionfieldmacula_station_announcer:inject_identity_metadata/1has stamped onto every station's re-announce heartbeat since before this reader existed — write-only until now, so no consumer (includinghecate-stations) could ever read a station's own reported build back out. Addedversionto the returned map. ## [10.13.1] - 2026-08-29 ### Fixed —call_station/call_stream_station/ensure_content_linkdialed a redundant duplicate connection to an already-connected stationmacula_client's link table is keyed by the literal seed STRING passed toensure_link/3. A direct-dial caller names its target by a URL it just resolved (astation_endpointrecord'squic://[host]:port), which very often spells the SAME physical station differently than however the pool's own configured seed (or an earlier direct-dial call to it) already named it. A literal-string miss dialed a genuinely second, redundant connection to a station the pool already held a live connection to — reproducible, live, 100% of the time, as literally the SECONDcall_stationfrom one pool to the same station, regardless of realm or procedure: the station closed one of the two duplicate connections, and whichever caller's next attempt landed on the closed one failed with{disconnected, {peer_closed, "connection lost"}}. Fixed: a literal-key miss now checks whether the caller suppliedexpected_node_id(every direct-dial caller does — it's the station identity already resolved and verified via a signed DHT record before reaching here) and, if so, scans this pool's existing links for one already connected to that same identity before dialing fresh. Only runs on a direct-dial literal-key miss — the pool's own plain seed-connect path is unaffected, and any subsequent call to the same resolved URL hits the ordinary literal-key match and skips the scan entirely. ## [10.13.0] - 2026-08-29 ### Added — station-local wildcard pubsub subscriptionshecate_pubsub:subscribe/3now treats a topic containing a literalsegment as a wildcard pattern (matched viamacula_topic_pattern:matches/2) rather than an exact topic — kept in a separate internal map so a realm with no wildcard subscribers pays no extra cost on delivery. A subscriber onrealm//app/domain/name_v1receives publishes to bothrealm/acme/app/domain/name_v1andrealm/contoso/app/domain/name_v1. **Station-local only, by design, this release**:topics/1(which feeds cross-station Bloom-gossip re-subscription inmacula-station) still returns exact topics only — a wildcard pattern is never propagated to peer stations, since a Bloom filter tests exact-string membership and a raw-bearing string would be meaningless there. A wildcard subscriber therefore only receives a publish that reaches this realm instance directly (same-station publisher, or one already fanned here via the ordinary exact-topic relay path) — not one arriving purely via gossip from a peer that has no exact-topic overlap. Mesh-wide wildcard subscription is a separate, larger piece of work, tracked inmacula-station/plans/PLAN_ORG_SCOPED_DISPATCH_AND_WILDCARD_DISCOVERY.md. ## [10.12.0] - 2026-08-29 ### Added —macula_topic_pattern:matches/2Segment-wise wildcard matching for hierarchical mesh addresses (pubsub topics, RPC procedure names, capability advertisements):matches exactly one segment, in exactly that position. Deliberately arity-agnostic — no assumption of a fixed segment count, so it serves bothhecate_om_capabilities's 2-segment capability names (org,name) andmacula_topic's 4-segment tiered topics (org-or-_org,app-or-_realm,domain,name) with the same primitive. Building block for wildcard capability discovery and wildcard pubsub subscription matching (hecate-services/hecate-om,macula-station/plans/PLAN_ORG_SCOPED_DISPATCH_AND_WILDCARD_DISCOVERY.md). ## [10.11.1] - 2026-08-29 ### Fixed —macula_direct_dial:publish_advertisement/5silently droppedttl_msadv_opts/1only ever forwardedcert_chainfrom a caller'sOptstomacula_record:procedure_advertisement/4— a single-clause match meant passingttl_msalone (nocert_chain) produced#{}, silently discarding it, even thoughprocedure_advertisement/4has readttl_msfrom its own Opts all along. Now forwards each recognized opt independently, so a caller ofadvertise_direct/7/publish_advertisement/5can set a proportioned TTL on the publishedprocedure_advertisementinstead of getting the envelope default regardless of what it asked for. Found wiring a proportionedttl_msthrough fromhecate_om_capabilities. ## [10.11.0] - 2026-08-29 ### Added —purge_subscriber/2on the pubsub overlay stack Newhecate_pubsub:purge_subscriber/2,hecate_pubsub_server:purge_subscriber/2, andhecate_pubsub_registry:purge_subscriber/2(the last one fanning out across every realm the registry currently holds a server for). Removes one subscriber pubkey from every topic it was on, dropping any topic that empties out as a result — the samedrop_or_keepruleunsubscribe/3already applies to one topic, generalized to "all topics this subscriber touched." Closes the missing half ofmacula-station/plans/DESIGN_SUBSCRIPTION_LIFECYCLE_GC.md: nothing in this SDK previously removed a subscriber's entries on connection loss, only on an explicit UNSUBSCRIBE frame. A station never had this primitive to call in the first place —macula_station_peer_observer:on_disconnected/2purges SWIM, DHT, ADVERTISE and stream state on disconnect already, but had nothing to call for pubsub, so a peer or daemon that vanished without unsubscribing left its topics permanently registered as local interest, whichmacula_station_peering_routerthen re-propagated to every other peer indefinitely. Wiring this into that disconnect path is amacula-stationchange, not an SDK one; this release only adds the primitive. ## [10.10.2] - 2026-08-28 ### Fixed — a text payload VALUE matching an existing atom name silently arrived as that atom, not a binary Real production crash:hecate-stations(a live directory service built on this SDK) ingested a genuinenode_recordfromstation-it-milan.macula.ioviafind_records_by_type/2and crashed a downstream RocksDB indexer, repeatedly, on every restart. The record'skind` field decoded as the *atomstationinstead of the binary<<"station">>— every other field on the identical record (hostname,city,country) decoded as an ordinary binary, which is what made it invisible until something finally choked on the one field that happened to collide. Root cause:macula_frame:from_wire_envelope/1— the RPC-response decode pathfind_record/2/find_records_by_type/2return records through, distinct from the DHT-storage wire codec (encode/1/decode/1) — deliberately collapses a{text, B}VALUE into the atomBwheneverBalready exists in the runtime's atom table (safe there: an undeclared name harmlessly stays{text, Bin})."station"is used as a literal atom throughout this codebase, so it collided;"Milan"is not, so it didn't.macula_record:payload_field/2already handled this exact class of decode-path variance for KEYS ({text, Name}/ bareName/safe_atom(Name)), but never accounted for it on the VALUE side.unwrap_text/1now converts an atom value back to its binary form before returning it frompayload_field/2— used by every `readfunction in this module — excepttrue/false/undefined/null, which are left as atoms (no current reader expects a boolean, andnullisread_tombstone/1's own explicit-absence marker fordetail). ## [10.10.1] - 2026-08-28 ### Fixed —read_node_record/1silently dropped fields the writer already storesnode_payload/5has writtenhostname/endpoint/city/country/lat/lng/display_name/caps_hint/peersinto everynode_recordsince v3.4.0, butread_node_record/1's typed-map reader stopped atnode_id/station_id/realms/capabilities/kind— the data was always on the wire, just unreachable through the public API (payload_field/2, the only thing that knows how to read either the canonical or wire-decoded key shape, isn't exported). Found buildinghecate-stations, a directory service that needs exactly these fields to answer "where is this station".lat/lngnow come back asfloat() | integer() | undefined:with_geo/3writes floats to 6 decimals but integers with no decimal point at all, so a plainbinary_to_float/1would crash on an integer-valued coordinate — the newparse_geo/1tries float first and falls back to integer. ### Added —read_tombstone/1, the typed reader fortombstonerecords Same gap asread_node_record/1above, one type tag over: atombstone(0x0C) could be built, signed, and verified, but nothing let a subscriber readsuperseded_key/superseded_type/replaced_at/reason/detailback out without reaching for the unexportedpayload_field/2.macula_station_announcerhas published a signed tombstone on every graceful shutdown since it existed; nothing outside this module could ever read one. Also found buildinghecate-stations, which needs to retire a station from its read model the moment itsnode_recordtombstone lands rather than waiting out the TTL.detailcomes backundefinedrather than the wire'snull—tombstone/3,4always writes the key, present-but-empty, unlike every other optional field in this module which is simply omitted when unset. ## [10.10.0] - 2026-08-27 ### Fixed —macula_diagnostics:event/2,3was silently dropped everywhere, always Root cause of a class of bug this session had already independently rediscovered and worked around three separate times (10.5.5'sdrop_unexpected/4, the listener'smaybe_emit_puzzle_invalid/3andduplicate_replacedwarnings in macula-station) without ever tracing it back to the actual source:event/3stamps every report withdomain => [macula]in its metadata. OTP's own stockdefaultlogger handler — confirmed with a bareerl, nosasl, no project config — shipsfilter_default => stopwith only two explicit allows: events whose domain is[otp, sasl](or a sub-domain of it), and events with no domain at all.[macula]matches neither. Every single call tomacula_diagnostics:event/2,3, in this SDK and in every consumer (macula-station's announcer, outbound_link, health_publisher, peer_observer'srelay_overlay/forward_overlay, listener'scap_exceeded), has been silently dropped before reaching any handler output since the day this module shipped. Confirmed end-to-end with a throwawaydefaulthandler pointed at a file:info-level events never appeared until this fix was in place. Fixed at the source, not by working around it at each call site again: newmacula_diagnostics:install_domain_filter/0adds an explicit{log, equal, [macula]}allow filter to thedefaulthandler, called once frommacula_app's ownstart/2— every consumer gets it for free just by depending onmacula, no per-release filter config to remember.macula_peering_conn'sdrop_unexpected/4, which carried thelogger:warning/2workaround and a comment explaining why, is reverted back tomacula_diagnostics:event/2(topic_macula.peering.unexpected_event) now that the underlying mechanism actually works. macula-station's own workarounds (maybe_emit_puzzle_invalid/3,duplicate_replaced) are addressed in that repo's own CHANGELOG once it picks up this version. Verified withtest/macula_diagnostics_tests.erl'sdomain_filter_fixes_the_actual_drop_test/0: reproduces the exact production filter chain (including kernel'slogger_level => infooverride, since OTP's stock primary-level default ofnoticewould otherwise mask the same symptom for an unrelated reason) against a temporarydefaulthandler backed by a real file, confirms the event is silently absent before the fix and present after it. Confirmed RED without the fix ({error, undef}on the not-yet-existing function) viagit stash, GREEN with it restored. ### Fixed — NIF discarded the real reason a QUIC stream closednative/macula_quic/src/stream.rs's recv-loop catch-all (Err(_e) => ... atoms::none() // simplified for now) collapsed every read error other than a peerReset— connection loss, timeout, anything elsequinn::ReadErrorcan return — into a barenoneatom. 10.9.1'sstream_closed/peer_send_shutdownhandling threads thisDetailstraight into thedisconnectednotification's reason ({peer_closed, Detail},{closed_during_handshake, Detail}, etc.), so every one of those reasons carried no actual diagnostic content. Now formats the realquinn::ReadErrorviaformat!("{}", e), matching the existing pattern already used two lines away in the same file forreset/senderrors. No Erlang-side change needed —Detailwas always passed through opaquely, so it now just carries a real string instead of alwaysnone. ## [10.9.1] - 2026-08-27 ### Fixed — connections could sit "connected" forever after their transport actually died Found while live-verifying 10.9.0'sreject/2against the real fleet: the surviving side of a rejected/closed connection sometimes logged[peering] unexpected state=handshaking event={quic, stream_closed, ...}and never disconnected. Root cause:handshaking/3,connected/3, anddraining/3each had a clause for{quic, closed, Conn, Detail}— an event **nothing ever sends**. Verified directly innative/macula_quic/src/atoms.rs/stream.rs/connection.rs: theclosedatom exists and a connection's ownclosedfield is a purely-localAtomicBool, but no code path anywhere callssend_event` with it. What the recv loop actually sends when the control stream dies for any reason — peer reset, connection loss, timeout, everythingstream.rs's ownErr(_e)catch-all collapses under a// simplified for nowcomment — is{quic, stream_closed, Stream, Detail}; a clean peer-initiated half-close is{quic, peer_send_shutdown, Stream, Detail}. Neither was handled anywhere in the state machine. Bothhandshakingandconnectedhad no other matching clause either, so every occurrence fell through todrop_unexpected/4, which (correctly, since 10.5.6) logs it and returns{keep_state, Data}— the connection just sits there. Nodisconnectednotification, no termination, indistinguishable from healthy tocontrolling_pid/accept_owneruntil some unrelated higher-layer liveness probe eventually notices. This is the same failure classproject_station_dead_but_healthy_milandocumented at the station-transport level, one layer down at the connection state machine itself. Replaced all three dead clauses with working ones forstream_closed/peer_send_shutdown(guarded to the control stream,Data#data.quic_stream, matching the codebase's own established pattern).draining/3's case was not a "stuck forever" bug (itsstate_timeoutalready terminates unconditionally) — just a missed opportunity to end the drain the instant the peer's transport confirms closure instead of always waiting out the full?DRAIN_TIMEOUT_MS. Verification, not assumption: newpeer_closing_notifies_the_surviving_side/1test (real Quinn QUIC pair) — reject one side, confirm the OTHER side (which did not initiate anything) still getsdisconnectedand terminates, within 2s. Confirmed RED without the fix (revertedmacula_peering_conn.erlalone, reran — the new test failed exactly as expected) before trusting GREEN with it restored. Broader suite (macula_peering_conn,macula_peering,macula_peering_handshake_tests— now 8 tests,macula_peering_dial_trust_tests,macula_peering_recipient_tests,macula_frame,macula_station_link_tests,macula_identity— 275 tests total) passes clean. ## [10.9.0] - 2026-08-27 ### Added —macula_peering:reject/2, closing the puzzle-enforcement drain window Follow-up to the overlay_relay root cause ([10.5.9]). That incident's mechanism was: a puzzle-invalid peer's connection promotes toconnected(the SDK's state machine has no knowledge of a station's puzzle policy), and only afterward doesmacula_station_listenerdecide to reject it — usingmacula_peering:close/2, which transitions throughdrainingfor?DRAIN_TIMEOUT_MS(5 seconds), during whichdraining/3's "ignore late inbound during drain" clause silently accepts and discards any further traffic by design. That's correct for a peer whose session was genuinely trusted and is simply ending; it's pure exposure for a peer that was never trusted in the first place — flagged explicitly as real hardening material still worth doing, not done in that release.reject/2isclose/2's counterpart for exactly that case: no GOODBYE, nodraining, straight to{stop, normal, Data}. Added matching{reject, Reason}clauses to every state (connecting,awaiting_start,handshaking,connected,draining) — in every state exceptconnectedthis is identical to whatclose/2already does there (nothing has been established yet, so immediate termination was already correct);connectedanddrainingare where the actual fix lives.macula_station_listener:reject_handshake/3now callsreject/2instead ofclose/2forpuzzle_invalid. This narrows the exposure window from the full 5s drain to, at most, whatever's already in the connection process's mailbox at the moment of rejection — not a mathematically perfect elimination (that would need synchronous admission control gating the SDK's ownconnectedtransition, a much larger change not currently justified) but a genuine, order-of- magnitude reduction of a real, measured gap. Also fixed:macula_station_listener:maybe_emit_puzzle_invalid/3(macula-station) usedmacula_diagnostics:event/2, the same domain-filter logging bug fixed elsewhere in the 10.5.x line — a function whose whole purpose is letting an operator see rejection volume before flippinglog_onlytoenforcewas silently unobservable. Switched tologger:warning/2. Found and fixed a real, unrelated pre-existing bug while adding tests:macula_peering_handshake_tests.erl's clienttargetoptions never setverify, which defaults towebpki(documented as "the default since 5.0.0") — meaning every test in this file was rejecting its own self-signed test certificate withUnknownIssuerand had apparently been doing so for a long time, just never caught by a careful full-file run. Addedverify => none, matching the established self-signed-test-cluster convention used everywhere else in this codebase. All 7 tests in the file pass now, including the new one — which also serves as direct proof of the fix: "close" takes 5.002s (still drains, unchanged), "reject" takes 0.002s (immediate, the whole point). Verification: newreject_terminates_immediately/1end-to-end test (real Quinn QUIC pair, not a state-machine mock) confirms both thedisconnectednotification and process exit land within 500ms, never anywhere near the 5s drain window. Broader suite (macula_peering_conn,macula_peering,macula_peering_handshake_tests,macula_peering_dial_trust_tests,macula_peering_recipient_tests,macula_frame,macula_station_link_tests— 254 tests) passes clean. ## [10.8.0] - 2026-08-27 ### Changed — the live wire codec is now the native deterministic CBOR encoder 10.7.0 shippedmacula_cbor_nif:pack_deterministic/1andunpack_deterministic/1as an additive, differentially-tested-but-unused capability. This release wires it in: all 9 real call sites acrossmacula_frame.erl,macula_record.erl, andmacula_manifest.erlthat used to callmacula_record_cbor:encode/1/decode/1now callmacula_cbor_nif:pack_deterministic/1/unpack_deterministic/1instead — every frame sent or received on the mesh, every signed record, and every content manifest hash now goes through the native codec.macula_record_cbor.erlitself is untouched and stays live as the differentially-tested reference implementation (still exercised by its own full test suite); nothing calls it for real traffic anymore. Found and fixed a real performance bug before wiring anything in: the native decoder was originally slower than the pure-Erlang reference for every payload size tested (15-43% slower, worst on medium-sized frames) — it paid a fresh allocation + full copy (OwnedBinary::new+copy_from_slice) for every byte-string/text- string field, where Erlang's own<<B:Len/binary, Rest/binary>>sub- binary pattern match pays neither for a refc binary. Fixed by threading the original inputBinary<'a>through the whole recursive descent and usingBinary::make_subbinary/2(a genuine zero-copy reference) instead. Benchmarked before wiring in (representative small/medium/large frame-shaped payloads, 50k iterations each, two independent runs): encode 2.6-4.2x faster, decode 1.24-1.62x faster, both directions, both runs — a real, reproducible net win, not a wash. Verification: full targeted suite (macula_frame,macula_record,macula_record_cbor,macula_record_uuid,macula_record_cert_chain,macula_record_content_announcement,macula_manifest,macula_identity,macula_peering_conn,macula_peering,macula_cbor_nif,macula_cbor_deterministic_diff_tests— 455 tests) passes clean. Full project eunit suite: 1586 passed, 1 failed before the run cascaded/aborted (a pre-existing flake inmacula_station_link_tests, confirmed unrelated to this change by running that module in isolation — 53/53 pass there, matching the already-documentedmacula_full_eunit_suite_flaky_under_loadpattern). Rebuilt the NIF from scratch via the realpriv/build-nifs.shpipeline (not a manualcargo build+ copy) to confirm the hex-publish path produces the same result a consumer'srebar3 compilewould. No wire-format change: this is the entire point of shipping 10.7.0 first and differentially testing it — the bytes this produces are identical to whatmacula_record_cboralways produced, verified by construction (65 differential tests including 3000+ randomized trials across two seeds) rather than assumed. Any station or client on an older macula version interoperates unchanged. ## [10.7.0] - 2026-08-27 ### Added — native deterministic CBOR codec (additive, NOT wired into the live frame path)macula_cbor_nifgainspack_deterministic/1andunpack_deterministic/1, a from-scratch native implementation ofmacula_record_cbor.erl's exact RFC 8949 §4.2.1 deterministic subset — the codec everymacula_frameandmacula_recordencode/decode actually goes through today, and therefore the thing every signature verification in the mesh depends on producing byte-identical bytes. This is the headline NIF opportunity identified while surveying the SDK for further native-acceleration candidates:macula_record_cboris pure Erlang despite this crate's existingnif_pack/nif_unpacksitting right next to it — but that existing pair goes throughciborium::value::Value, a generic, non-deterministic representation (no canonical map-key order, no forced integer/float widths, lossy atom/tuple handling), so it isn't a drop-in for the wire protocol's actual requirements. The new functions bypassciboriumentirely and operate directly onrustler::Term, implementing the same value model by hand: non-negative integer -> uint (major 0), negative integer -> major 1 (encoded count =-1-N, full range down to-(2^64)viai128, unconditionally available in rustler 0.34 with no feature flag), binary -> byte string,{text, Binary}-> UTF-8 text string (bytes used as-is, no UTF-8 validation, matching the Erlang encoder exactly), atom (encode-only, notnull) -> UTF-8 text via its own name, list -> array, map -> map with keys sorted by the bytewise order of their own encoded bytes,null-> simple null, float -> always binary64 on encode (decode accepts 16/32/64-bit). Every decode path is on the hot path for untrusted, network-received bytes and is written to never panic — nounwrap/expect/unchecked slice indexing anywhere; every length and offset is bounds-checked before use, and every "no matching clause" case in the Erlang reference (major-7 additional info outside {22,25,26,27}, major-6 tags, trailing bytes after the top-level value, NaN/infinity in a half-float) becomes an explicitrustler::Error::RaiseTerm— genuinely raising, matchingmacula_record_cbor:decode/1's real crash-on-malformed-input contract, notError::Term's different "return{error,_}normally" behavior. Verified, not assumed:test/macula_cbor_deterministic_diff_tests.erldifferentially tests the new codec againstmacula_record_cboracross the exact boundary vectorsmacula_record_cbor_tests.erlalready treats as load-bearing (uint/negative-int width boundaries, empty/full binaries and text, nested maps, atom-as-map-key, the-(2^64)extreme), plus a 3000-iteration seeded random generator (same style asmacula_frame_tests's owncheck_payload_soundness_holds_on_generated_terms_test_) covering deeply nested maps/arrays/mixed types, run against two different seeds, plus 14 malformed/truncated input cases asserting the decoder raises cleanly rather than panicking. All 65 tests pass; the full macula_identity/peering_conn/peering/frame/record_cbor/cbor_nif suite (326 tests) passes unchanged. Deliberately NOT done here:macula_frame.erl/macula_record.erlstill callmacula_record_cbor, unchanged. Swapping the live wire codec is a separate, higher-stakes step — this release only makes the native codec exist and prove itself byte-for-byte identical. ## [10.6.0] - 2026-08-27 ### Added — native puzzle grinding inmacula_crypto_nifmacula_identity:generate(#{puzzle => true})now grinds the S/Kademlia identity puzzle (see [10.5.9]) natively via a newnif_grind_puzzle/1inmacula_crypto_nif, instead of loopingcrypto:generate_key/2+crypto:hash/2one candidate at a time from Erlang. Motivated directly by the overlay_relay incident: puzzle enforcement is live on the real fleet, and a difficulty high enough to matter as Sybil resistance is exactly the kind of long, CPU-bound search a BEAM scheduler thread shouldn't run — the new NIF isschedule = "DirtyCpu"so it doesn't block a normal scheduler either way.macula_crypto_nif:grind_puzzle/1follows the module's existing NIF-with-Erlang-fallback pattern (generate_keypair/0,sha256/1, etc.) — the fallback (erlang_grind_puzzle/1) is the same loopmacula_identityused to run itself, kept only for architectures where the NIF fails to load.macula_identity's owngrind/1andgrind_loop/3are removed as dead code now thatgenerate/1delegates directly;puzzle_valid/1,2,puzzle_evidence/1, andhas_leading_zero_bits/2are unchanged and still the source of truth the NIF's Rust implementation mirrors exactly (same evidence: SHA-256 of the raw 32-byte public key; same bit-prefix check). Benchmarked at difficulty 16 (20 trials): ~998ms/grind natively vs. ~2470ms/grind via the old Erlang loop — about 2.5x. The native implementation originally benchmarked slower than the Erlang loop (OsRngdraws hit the OS entropy source, a syscall, on every candidate key); fixed by seeding aStdRngonce from OS entropy and drawing from that buffered CSPRNG for the whole grind, which is where essentially all of the win comes from — worth remembering if grinding is ever extended to run in parallel across dirty schedulers. ## [10.5.9] - 2026-08-27 ### Root-caused: overlay_relay was never broken — the fleet's puzzle enforcement was rejecting test identities The overlay_relay WAN-only vanishing-frame incident (opened at 10.5.0) is closed. It was never a bug inoverlay_relay, in the QUIC/Rust layer, in frame codec/parsing, or in any relay logic. Confirmed by resending the exact same reproduction with a puzzle-solving identity (macula_identity:generate(#{puzzle => true})): the relay succeeds end-to-end, with correct sender attribution and correct realm/payload preservation. The chain that made non-puzzle-solving test identities appear to break it: 1.station-de-frankfurt.macula.ioruns withpuzzle_enforcement = enforce(an operational config choice, not the SDK'soffdefault). 2. A freshly-generated (non-puzzle-solving) identity's handshake is allowed to complete and reachconnected— the SDK's own state machine has no knowledge of the puzzle check. 3. macula-station'son_handshake_complete/3independently validates the puzzle immediately after, and underenforcewith an invalid puzzle, closes the connection (macula_peering:close(Pid, puzzle_invalid)). 4. That transitionsconnected → draining.draining's handling of further inbound bytes is an intentional, by-design silent drop ("Ignore late inbound during drain") — no log, no counter. Any frame sent in the split-second before this closes — including the test's own overlay_relay frame — is silently discarded. 5. This is why it "only failed on the real fleet": local test stations never hadpuzzle_enforcementconfigured, so they default tooffand the reject path never fires there. It was never a network-latency-sensitive race. ### Reverted — all temporary diagnostics from 10.5.1 through 10.5.8native/macula_quic/src/{stream,connection,message}.rsandsrc/peering/macula_peering_conn.erlare restored to their 10.5.0 state, exceptdrop_unexpected/4, whoselogger:warning/2fix (10.5.6) is kept — that one was a real, pre-existing bug (unobservable logging), not investigation-only instrumentation, and reverting it would silently reintroduce it. ### Follow-ups (not done here, worth doing separately) - Theconnected → drainingwindow itself is real hardening material: a connection that has already been decided invalid can still accept and silently swallow traffic for one message before the close takes effect. Rejecting the puzzle check before promoting toconnectedwould close that window rather than merely making it debuggable. -macula_station_listener.erl'smaybe_emit_puzzle_invalid/3still usesmacula_diagnostics:event/2and is silently dropped for the same domain-filter reason as everything else in this incident — worth the same one-line fix asduplicate_replacedgot. -macula_diagnostics:event/2,3'sdomain => [macula]metadata being unconditionally dropped by the defaultsasl-enabled logger handler is a real, fleet-wide observability gap independent of this incident. Either fix the filter chain (allow[macula]explicitly) or stop usingdomainmetadata inmacula_diagnosticsuntil Phase 7's real exporter lands. ## [10.5.8] - 2026-08-27 ### Added — logs the closeReasonon theconnected→drainingtransition (TO BE REVERTED) 10.5.7 proved the overlayrelay frame is swallowed bydraining's intentional, by-design silent late-inbound drop, on A's own connection to the station, ~211ms after the frame was sent. The only transition intodrainingfromconnectedisconnected(cast, {close, Reason}, Data) -> {next_state, draining, Data}— this logsReasonthere directly, to confirm (rather than infer by elimination) whether it isreplaced_by_newer_handshake, macula-station's own duplicate-handshake guard (macula_station_listener:maybe_close_old_worker/3) — the only close-call site whose timing profile fits a fresh connection being closed within a few hundred ms of first use, rather than a dial timeout or app-silence timeout. No functional change. Follow-up patch removes all of 10.5.1's through this logging once the incident is root-caused. ## [10.5.7] - 2026-08-27 ### Added — diagnostic on thedrainingstate's silent late-inbound drop (TO BE REVERTED) 10.5.6'sdrop_unexpected/4fix proved the overlay_relay frame's raw{quic, Bin, Stream, Flags}message is never caught there either — noevent_type=infounexpected event ever fires for it, on top ofconnected/3's own frame-processing clause never matching it (10.5.5'sparse_streamdiagnostic never once loggedbin_size=499). That leaves exactly one remaining code path: `draining(info, {quic, , , }, Data) -> {keepstate, Data}— an intentional, by-design silent drop ("Ignore late inbound during drain") with no logging at all. It matches every symptom observed across 10.5.1-10.5.6: bytes read correctly, delivered to the correct, unchanged-since-birth stream owner's mailbox, and then gone without any trace, by design. Logspeer_node_idand payload byte size whenever this clause fires, using plainlogger:warning/2(see 10.5.5/10.5.6 for whymacula_diagnostics:event/2would not work here). **No functional change.** Follow-up patch removes all of 10.5.1's through this logging once the incident is root-caused. ## [10.5.6] - 2026-08-27 ### Fixed —drop_unexpected/4's own logging was silently dropped, same as 10.5.4's Not a temporary diagnostic this time — a real, pre-existing bugfix.drop_unexpected/4ismacula_peering_conn's catch-all for any event a connection's current state doesn't handle, and its entire purpose is observability: log_macula.peering.unexpectedand keep going. It usedmacula_diagnostics:event/2, which suffers exactly the filter-chain bug fixed for this incident's own diagnostics in 10.5.5 (see that entry) —domain => [macula]metadata silently dropped by the defaultlogger_std_hhandler on any release built withsasl. A function whose only job is to be observed was, in practice, unobservable on every station in the fleet. Switched to plainlogger:warning/2. Found while root-causing the overlay_relay WAN-only vanishing-frame incident: 10.5.5'sparse_streamdiagnostic proved the frame's raw bytes never reachconnected/3's frame-processing clause at all (itslogger:infofiring correctly for every other frame type in the same capture, but never once with the overlay_relay frame's exact byte count) —drop_unexpected/4is the only remaining code path capable of silently absorbing it, and its own logging bug meant nobody could have seen it fire even if it did. ## [10.5.5] - 2026-08-27 ### Fixed — 10.5.4's diagnostics were silently dropped by the default logger filter chain Not a code-path bug: a logging-visibility one, found while trying to read 10.5.4's output on the fleet.macula_diagnostics:event/2,3stampsdomain => [macula]on every event. The defaultlogger_std_hhandler on any release that includessasl(every macula-station box) installsfilter_default => stopplus filters that only explicitlylogtwo things:[otp, sasl]-domain reports and events with **no** domain metadata at all. Anything else — including every singlemacula_diagnostics:eventcall, this incident's temporary diagnostics and macula-station's own pre-existingoverlay_relay_stats-adjacent events alike — falls through every filter unmatched and is dropped byfilter_default => stop. Confirmed directly on the live Frankfurt node: a manually-triggeredlogger:logwithdomain => [macula]metadata never reacheddocker logs; the identical report with no domain metadata did. Practical effect on this investigation: the three diagnostic cycles between 10.5.3 and this one (peer_observer'sroute/4andon_framelogging in macula-station, and this SDK's ownnotify_frame/parse_streamlogging added in 10.5.4) produced **no information at all** — not a negative result, just silence, indistinguishable from "never executed." The only diagnostics unaffected by this bug are theoverlay_relay_stats/0counters (persistent_term/counters, not the logger) and the Rust-sideeprintln!calls inmacula_quic(bypass Erlang's logger entirely). 10.5.4'sparse_streamandnotify_framediagnostics now use plainlogger:info/2(no domain metadata) instead ofmacula_diagnostics:event/2, so they are actually observable. This SDK release does not fix the underlying filter-chain default itself (that lives in macula-station's release config, not here) — it only makes this incident's own temporary instrumentation visible again. **No functional change.** Follow-up patch removes all of 10.5.1's through this logging once the incident is root-caused. ## [10.5.4] - 2026-08-27 ### Added — SDK-side send-path diagnostic inmacula_peering_conn(TO BE REVERTED) Follow-up to 10.5.3, and a change of layer. 10.5.1-10.5.3 proved the overlay_relay bytes are read correctly, delivered to the correct, unchanged-since-birth stream owner, and enqueued into that Erlang process's mailbox (send_and_clearreturnsOk(())). Two further diagnostics added directly to macula-station's ownmacula_station_peer_observer.erl(not requiring this SDK, since that module is macula-station's own code) then showed something unexpected: an unconditional log at the very entry ofon_frame/3— which every frame taking the "legacy controlling_pid" path must pass through — never fired even once during a full reproduction capture, for this frame or any other. That points further upstream than macula-station's own dispatch logic, back into this SDK's ownmacula_peering_connmodule, which is the thing actually responsible for delivering{macula_peering, frame, ConnPid, Frame}tocontrolling_pidin the first place. This adds three unconditional diagnostics:connected/3's handling of{quic, Bin, Stream, Flags}now logsparse_stream/1's actual frame count for each read (distinguishing a clean parse from a silent{more, }stall or a silently-swallowed{error, bad_frame}, both of which look identical from outside);notify_frame/2andnotify_bypass/5now log the resolvedcontrolling_pidtarget and whether it was alive at send time, immediately before thePid ! Msgsend that is the last SDK-owned step before the message left for macula-station's mailbox. **No functional change.** Follow-up patch removes all of 10.5.1's through this logging once the incident is root-caused. ## [10.5.3] - 2026-08-27 ### Added — stream-ownership identity diagnostic inmacula_quic(TO BE REVERTED) Follow-up to 10.5.2. 10.5.2 provedmessage::send_data'senv.send_and_clear(...)returnsOk(())on the fleet for the exact overlay_relay frame — the bytes are correctly read AND successfully enqueued into a live Erlang process's mailbox, yet nothing downstream ever observes the message, not evenmacula_peering_conn's own catch-all clause (drop_unexpected/4`, which already logs and does not fire). A message enqueued into a mailbox that nothing ever matches on is consistent with delivery to the wrong (but still-alive) process — so this instruments stream ownership identity itself. Adds abirth_owner: LocalPidfield toStreamResource, captured at stream-creation time.start_recv_loop's successful-read arm now logs whether the stream's current owner still matches its birth owner (owner_unchanged_since_birth);nif_controlling_processnow logs whether a reassignment actually changed the owner and whether the prior owner was still the birth owner (actually_changed,was_birth_owner).rustler::LocalPidhas noDebugimpl, so identity is compared, not printed directly. No functional change. Follow-up patch removes all of 10.5.1's, 10.5.2's, and this logging once the incident is root-caused. ## [10.5.2] - 2026-08-27 ### Added — one more temporary diagnostic line inmacula_quic(TO BE REVERTED) Follow-up to 10.5.1. Deploying 10.5.1's tracing to the fleet proved the overlayrelay bytes ARE correctly received by the Quinn/Rust layer (recv.read()returns the exact expected byte count, loop continues healthily, no error) — but the message never reaches ANY Erlang code: neither the intendedmacula_peering_connclause nor its own catch-all (drop_unexpected/4, which already logs and did not fire). That points atmessage::send_data'senv.send_and_clear(...)call itself, whoseResultwas unconditionally discarded (`let = ...) — the one part of the whole path never actually checked. This logs that result. **No functional change.** Follow-up patch removes both 10.5.1's and this logging once the incident is root-caused. ## [10.5.1] - 2026-08-27 ### Added — temporarymacula_quicdiagnostic logging (TO BE REVERTED)eprintln!-based tracing innative/macula_quic/src/{stream,connection}.rsat the NIF boundary:nif_send'swrite_allcall (stream id, byte length, result),start_recv_loop'srecv.read()outcomes (bytes read, EOF, reset, error),nif_setopt_active's active-flag transitions, and stream creation innif_open_stream/nif_async_accept_stream(stream ids + role). Lands on stderr, whichdocker logscaptures on the fleet. **Why:**overlay_relay(10.5.0, Layer 2 plan Phase 3.5) passes CI's local test-cluster suite but silently fails to deliver on the real 7-station fleet. Every layer ofmacula's own Erlang code (frame codec, send path, receive dispatch) has been individually proven correct via livedbg/recontracing and a local reproduction against the actual release binary — the failure is real-network-specific and below the Erlang layer. This logging is the next diagnostic step, not a fix. Neithertc netemdelay (30ms±5ms) nor delay+loss (20ms±5ms + 1%) reproduces it on loopback, so this instruments the one layer never directly observed: the Quinn/Rust NIF boundary itself. **No functional change.** Follow-up patch removes this logging once the incident is root-caused. ## [10.5.0] - 2026-08-26 ### Added —overlay_relayframe +macula_station_link:send_overlay_frame/3Point-to-point overlay-frame delivery by NodeId (Layer 2 plan Phase 3.5).send_overlay_frame/2(10.3.0) delivers only to whoever is on the other end of one specific connection — correct for a direct station-to-station link, but a realm member reachable through a relay had no way to actually address a specific third-party peer.send_overlay_frame/3(Client, TargetPeer, Frame)wrapsFramein a newoverlay_relayenvelope frame (peer+ opaque encodedpayload`) that a station forwards to whichever of its other connections authenticates asTargetPeer— seemacula-station's own changelog/commit for the relay side.{error, not_connected}if the peering handshake to the station itself hasn't completed; silently dropped by the station ifTargetPeerisn't currently connected there (HyParView's own shuffle/retry is the recovery path, same as it already tolerates ordinary packet loss).send_overlay_frame/2is unchanged. ### Added —macula_record:read_node_record/1Mirrors the existingread_station_endpoint/1— turns a decodednode_recordpayload back into a typed map (node_id,station_id,realms,capabilities,kind). The one reader that was missing; every other record type already had one. ### Fixed —overlay_subscribe/3'sMeta.senderwas the wrong identity for a relayed frame Delivering a frame that arrived via the newoverlay_relayenvelope now stampsMeta.senderfrom the envelope's ownpeerfield (the true logical HyParView peer that originated it) instead ofstate.peer_node_id(this connection's own directly-connected peer — always a station's identity once a relay hop exists, never the actual third-party sender). A real bug found while designing point-to-point relay, not shipped before this version had a consumer that could distinguish the two. ## [10.4.0] - 2026-08-26 ### Added — HyParView + Plumtree overlay, absorbed from macula-hyparview/macula-plumtree The standalonemacula-hyparviewandmacula-plumtreepackages — extracted frommacula-station'sapps/hecate_overlay/earlier the same day — are folded into this SDK undersrc/overlay/, rather than published as two more loosely-coupled repos. Both were only ever going to be consumed alongsidemaculaitself (they already depend onmacula_record/macula_frame/macula_identity), and keeping them separate meant a two-package version-coordination step before Phase 4 of the realm-membership work (macula-io/macula-realm-identity) could even build against the client-facing overlay transport this SDK shipped in 10.3.0. Module names are unchanged on the move, matching this org's established extraction convention (keep the name when there's an existing external caller by that exact name, rename freely otherwise):hecate_plumtree,hecate_pubsub,hecate_pubsub_server,hecate_pubsub_registry, andhecate_or_setkeep their `hecate_names becausemacula-station's ownmaculastation_supstartshecate_pubsub_registrydirectly as its pubsub backbone — folding them in here required no code changes inmacula-stationbeyond its dependency declaration.macula_hyparview_view,macula_hyparview_proto, andmacula_hyparview_endorsementalready carried themaculaprefix from their own earlier rename and needed none. Not carried over:maculaplumtree_app/macula_plumtree_sup, the standalone package's OTP application shell — an empty supervisor with no children (its own moduledoc: "no children are owned here") that existed only soapplication:start/1had something to call.maculaalready provides that. New guides: [docs/guides/overlay/HYPARVIEW_GUIDE.md](docs/guides/overlay/HYPARVIEW_GUIDE.md) and [docs/guides/overlay/PLUMTREE_GUIDE.md](docs/guides/overlay/PLUMTREE_GUIDE.md), each with a new SVG diagram (assets/hyparview_views.svg,assets/plumtree_broadcast_tree.svg). No supervised OTP wrapper exists yet for either protocol (unlike RPC/PubSub/Content/Streaming), so each guide covers both the "why" and the raw functional API, including the realm-gated admission flow built onmacula_record:realm_member_endorsement/2,3and this SDK's own overlay transport (macula_station_link:overlay_subscribe/3,send_overlay_frame/2, 10.3.0). Verified:rebar3 compile xref eunit ct dialyzerclean (1940/1941 eunit — the one failure is the pre-existingmacula_station_link_tests: disconnect_notifies_subscribers_testRef-comparison race noted in 10.3.0's own development, unrelated to this change and untouched by it),rebar3 as lint lintclean,rebar3 exdocbuilds with both new guide pages and both new diagrams rendering correctly (checked in a real browser, not justxmllint).macula-station's dependency on the standalonemacula_plumtreegit package is removed in the same pass — see its own CHANGELOG. ## [10.3.0] - 2026-08-26 ### Added —macula_station_link:overlay_subscribe/3,overlay_unsubscribe/2,send_overlay_frame/2A client (daemon, ormacula-io/macula-realm) can now actually send and receive overlay-protocol frames — HyParViewhyparview(10.2.0), Plumtreeplumtree_, and any future frame type the built-in call/event handling doesn't recognise. Previouslyonframe/2's catch-all silently dropped every such frame; nothing in the SDK's client-facing API could send or subscribe to one either. SWIM and content-transfer frames are unaffected — they already have their own dedicated paths and never reach the new catch-all-turned-fan-out clause.overlay_subscribe/3registers interest per realm (no topic dimension, no wire-level SUBSCRIBE/UNSUBSCRIBE round trip — these frames already arrive addressed at a specific connection, not fanned out by topic like PUBLISH/EVENT) and delivers{macula_overlay_frame, SubRef, Frame, Meta}(Metacarriessender, the connected peer's NodeId — a frame doesn't self-identify its sender at the application layer) or{macula_overlay_gone, SubRef, Reason}on disconnect.send_overlay_frame/2is a raw transport primitive: the caller builds and signs the frame itself (e.g. viahecate_overlay_proto:build_join/1), this just puts it on the wire. This is the piecemacula-station'shecate_overlayadmission-gating fix (10.2.0) needed a consumer for — the protocol logic and the endorsement wire format existed, but nothing could actually drive a HyParView session from an Elixir client until now. ### Fixed — elvisno_deep_nestingviolations Six pre-existing level-3 nestings, all the same shape: a spawned worker'sfun() -> ... endwrapping acase/tryone level too deep for this repo'smacula_minruleset (limit 2). Extracted each into a named top-level function the spawn just calls, inmacula_content_transfer.erl(two sites),macula_download.erl,macula_feeder.erl,macula_pusher.erl,macula_dist_relay_client.erl, andclient/macula_station_link.erl— no behavior change,rebar3 as lint lintnow passes clean. ## [10.2.0] - 2026-08-26 ### Added — HyParViewhyparview_join/hyparview_forward_join/hyparview_neighborcan carry arecordThe three HyParView admission-relevant frames (Part 3 §7.1, shipped wire-format-only in the 5.x line) gain an optionalrecordfield carrying amacula_record:m_record()— e.g. a signedrealm_member_endorsement(Part 6 §9.6) proving the frame's subject is authorised to join a realm's overlay. Reuses the existing genericprepare_records/1/restore_records/1encode/decode machinery already used bystore/replicate(same field name, same automatic CBOR handling — no manual encode/decode needed by callers). Backward compatible: omittingrecordproduces the exact same frame shape as before. This closes a real gap found downstream inmacula-io/macula-station'shecate_overlayapp:hecate_realm_join:build_join/4computed a signed endorsement and then discarded it (no frame field existed to attach it to), so admission gating could never actually verify anything.hyparview_neighborneeded the same field for a separate reason — it can arrive unsolicited (shuffle-driven promotion), not only as an ack to a JOIN the receiver itself initiated, so it's an admission event in its own right and needed to be able to prove membership independently. ## [10.1.1] - 2026-08-23 ### Fixed —get_content/2,get_content_station/4,5, andmacula_downloadcrashed on a malformed MCIDmacula_content_transfer:is_chunked/2dispatches on an MCID's two-byte codec prefix (16#55single-block,16#56chunked manifest) and has no catch-all clause — any other shape raisedFunctionClauseErrorin the linked worker (and, forget_content, in the calling process too, viagen_server:call(..., :await, :infinity)). Reachable with attacker- or corruption-supplied input on any path that decodes bytes into an MCID before fetching it: a content-addressed image/file proxy serving ahex_string -> get_contentroute, a stored reference that got corrupted, or a share link.get_content/2,get_content_station/4,5, andmacula_download'sstart_link/4,5(init/1) now validate the MCID's shape before dispatching, returning{error, invalid_mcid}instead of crashing.macula_feeder/put_contentwere never affected —is_chunked/2'sputclause dispatches on byte size, not shape. ## [10.1.0] - 2026-08-23 ### Added —reuse_supopt formacula_streamer/macula_responseadvertise/6A station's wire-level registration for a procedure (itsmacula_remote_advertise_registryentry) is tied to whichever connection sent theADVERTISEframe, and does not survive that connection being replaced (reconnect, station-side eviction, a newer handshake from the same identity superseding the old one). Nothing previously re-sent that frame after the initial advertise, so a long-running provider's registration could silently go stale while its own localadvertised => truebookkeeping never noticed. A periodic re-advertise was the obvious fix, blocked by one thing: plainadvertise/5,6starts a brand new factory supervisor on every call, so calling it on a timer leaked one orphaned supervisor per tick.reuse_sup => Sup(the pid the first call returned) skips that — re-sends the wire frame (and, viaadvertise_direct/6,7, re-publishes the DHT record) against the existing supervisor instead of starting a new one. Confirmed live: this was the last piece of a long-runningunknown_next_peerinvestigation traced through hecate-tube, macula-realm, and two macula-station bugs this same day — seemacula10.0.1/10.0.2 and hecate_om 0.14.1/0.14.2's own CHANGELOG entries for the rest of the chain. - **src/macula_streamer.erl**, **src/macula_response.erl**:advertise/6acceptsreuse_supinOpts;advertise_direct/6,7forward it through (already forwardedOptswholesale). Purely additive — omittingreuse_supkeeps the exact prior behavior. ## [10.0.2] - 2026-08-23 ### Fixed —macula_identity:save/2crashed instead of returning{error, Reason}save/2's own-specpromisesok | {error, term()}, but the implementation didok = filelib:ensure_dir(Path)— a bare match that raised an unhandledMatchErrorwheneverensure_dirfailed (unwritable parent directory, misconfigured path, etc.), instead of returning{error, Reason}like every other failure branch in this function already does. Found live:macula-realm'sMaculaRealm.Meshcallssave/2from inside a required, supervisedGenServer, reasonably trusting the documented contract — notry/catcharound it. The unhandled crash took the entire hosting OTP application down with it (repeatedinitcrashes exceeded the supervisor's restart intensity),Ecto.Repoincluded, whenever the configuredmesh_identity_pathwasn't writable — reproduced with a non-writable default path on a box where it wasn't provisioned. - **src/identity/macula_identity.erl`*:save/2now dispatches on theensure_dirresult instead of asserting it, returning{error, Reason}on failure like the rest of the function already does. No behavior change on the success path. ## [10.0.1] - 2026-08-23 ### Fixed — direct-dial advertisement publish failures were silently swallowedmacula_streamer:advertise_direct/6,7andmacula_response:advertise_direct/6,7discarded the result ofmacula_direct_dial:publish_advertisement/5with ` = .... The DHT publish is intentionally best-effort — a provider stays reachable via the pooled path even if it fails — but "best-effort" was implemented as "the caller never learns", not "the caller keeps working and finds out". A provider whose publish failed once hadadvertised => falseforever, with nothing anywhere to say why, since nothing retries and nothing logs. Found live: a hecate-tube instance'stubemesh_providersreachedadvertised => truefor the first time after an unrelated identity fix, then direct-dial calls into it still failed with{unresolved, procedure_not_advertised}. Tracing it back showedmacula_direct_dial:publish_advertisement/5returning{error, timeout}on every call, three times in a row, well after any startup race could explain it — and neitheradvertise_direct/6,7caller ever surfaced that. - **src/macula_streamer.erl**, **src/macula_response.erl**: bothadvertise_direct/7now log a?LOG_WARNINGwhen the publish fails, naming the procedure and the reason, instead of discarding it. The return contract is unchanged —{ok, Sup}still comes back even on publish failure, matching the documented best-effort design; only the silent discard is fixed. This does not fix aput_recordtimeout itself, if the underlying DHT publish is failing for some other reason — it makes that failure observable instead of invisible. ## [10.0.0] - 2026-08-22 ### Removed — macula-net L3 substrate Deleted the sovereign-IPv6 overlay entirely: crypto-derived addressing, the TUN device, DHT-backed station/address resolution, the hosted-identity gateway, and their dedicated observability. Verified before removing — see rationale below, not a routine cleanup. - **Source**:src/macula_net/,src/route_packet/,src/deliver_packet/,src/derive_address/,src/manage_tun_device/,src/advertise_station/,src/resolve_address/,src/cache_route/,src/host_identity/,src/attach_identity/,src/host_attach_controller/— all 10 macula-netsrc_dirsentries, plussrc/observability/macula_metrics.erl,macula_metrics_http.erl,macula_packet_trace.erl(their moduledocs named them macula-net-only; confirmed no other caller before deleting). - **macula_root.erl**: dropped themetrics_children/metrics_http_childrenobservability wiring — it started only those two now-removed workers. - **macula_record.erl**: removedaddress_pubkey_map/2,3,host_delegation/5,6,sign_host_delegation/2,verify_host_delegation/1,hosted_address_map/3,4and their storage-key clauses (type tags0x13,0x14— retired, not reassigned, in case a station somewhere still holds a stored record under either). **Keptstation_endpoint/2,3andstation_endpoint_key/1/read_station_endpoint/1in full** — grep confirmed live callers inmacula_feeder,macula_pusher,macula_direct_dial, andmacula_request: every station publishes its ownstation_endpointautomatically and direct-dial (RPC/content/ streaming) resolves through it. It only ever shared a directory with macula-net, not macula-net's logic. Its TTL macro survives renamed?MACULA_NET_TTL_MS→?STATION_ENDPOINT_TTL_MS. - **Native**: deletednative/macula_tun_nif/(Rust,tun-rs) and itspriv/build-nifs.shbuild step. It was never in the hex package's ownfileslist inmacula.app.src(now removed there too) or inrebar.config's{hex, [{files, ...}]}— the published package never shipped it. - **macula.app.src**: ~~droppedinetsfromapplications~~ — **kept.** The first pass assumedmacula_metrics_httpwasinets's only caller and dropped it;rebar3 dialyzercaught a live second caller before release (macula_relay_discovery:fetch_topology/1, bootstrap topology over HTTPS — unrelated to macula-net), soinetsstays. - **Tests**: 25 test files covering the substrate, its phases, and its e2e/bench suites. - **Scripts**: 11 demo/soak scripts (lan-demo.sh, 5×lan_demo.erl, 4×netns-demo.sh,soak.sh). - **Docs**: one table row indocs/guides/DEVELOPMENT.md. ### Why Dormant since 2026-05-08 (Phase 4.7) — no commits in over three months while the SDK shipped v3.15 through v9.13.8. Live in the code at removal time:maculanet_transport_quic.erlgenerated a throwaway self-signed cert with server-name verification explicitly skipped (%% Phase 1: skip server name verification — self-signed certs.), andmacula_deliver_packet.erlstill stubbed ctrl/gossip envelope handling (%% ctrl/gossip handlers land in Phase 1.5+.) — both items Phase 1's own changelog entry named as deferred to "Phase 4 hardening," which shipped observability and benchmarking instead and never closed them. No multi-hop routing was ever built. Never exposed throughmacula.erl(its own facade wasmacula_net.erl), never in the README's feature list, never in the hexdocsextrasguide corpus — always a self-contained, undocumented side subsystem, not part of what this SDK advertises. Verified zero external dependents before removal: nothing in any other repo in the workspace callsmacula_net,macula_route_packet,macula_resolve_address,macula_advertise_station,macula_cache_route,macula_host_identity/macula_attach_identity/macula_host_attach_controller, ormacula_tun. Two doc-comment mentions elsewhere (macula-station's listener,hecate-daemon`'s DNS A-record synthesizer) are descriptive, not calls — one of them literally documents that the listener does not depend on macula-net.macula-testkit's own spike concluded macula-net's transport behaviour isn't reachable from the pub/sub path at all. The planning corpus (`macula-io/macula-architecture/plans/PLAN_MACULA_NET.md) drafted a Phase 5 (federation, deferred) and a Phase 6 (transport pluggability — the actual off-grid-mesh rationale: BATMAN wifi, LoRa, satellite as swappable transports), bothstatus: v1.0-draft, never implemented; that corpus is untouched by this change, since it lives in a different repo and remains the record of what was intended. ### Bump rationale MAJOR:macula_net,macula_tun, and every macula-net module were exported, callable public modules, even though undocumented in the README/guides. Removing them is a breaking change for any external caller, however unlikely one is, given the verification above. ### Notes -rebar3 compileclean (warnings_as_errorsis on — nothing left referencing a removed module). -rebar3 eunit: 1783 passed, 0 regressions attributable to this change. One test fails intermittently across repeated full-suite runs, but a *different* unrelated test each time (station-link disconnect handling on one run, seed-URL parsing on another) — a pre-existing timing flake in the suite, reproduced in isolation before this change and unrelated to anything touched here. --- ## [9.13.8] - 2026-08-21 ### Removed (documentation) - **docs/BENCHMARKS.md** — asked about directly as a follow-up to 9.13.7's cleanup ("what about BENCHMARKS?"). Verified before removing: stale (single dated run, 2026-05-03, ~3.5 months old at removal time), workstation-only and explicitly self-caveated as not representative ("Re-run on production-class hardware... this MD just establishes the substrate baseline isn't absurd"), scoped narrowly to one internal subsystem (the macula-net L3 substrate specifically, not the SDK broadly), carries a dead cross-repo link (macula-internal, the pre-2026-07-26-rename org name, mixed with a Codeberg-style URL path on a github.com host), not published to hexdocs, and not cross-referenced from anywhere else in the live docs tree — an orphan file. ### Notes - Norebar.configchange needed — this file was never inextras. -rebar3 ex_doc/compileclean; repo-wide grep confirms zero remaining live references outsideCHANGELOG.md. --- ## [9.13.7] - 2026-08-21 ### Removed (documentation) - **Removed public-inappropriate and stale content flagged directly by the maintainer:**docs/migrations/,docs/PLAN_SDK_3_16.md/PLAN_SDK_3_17.md/PLAN_SDK_3_17_PROGRESS.md,docs/HANDOVER_MULTIHOP_PUBSUB_PROPAGATION.md,docs/ROADMAP.md,docs/SOAK_2026-05-03_sanity.{csv,report.txt}. Verified each rather than removing on request alone: -docs/migrations/V1_TO_V2_PUBSUB.md— its own text says "V1 is gone" as of 4.0.0. Current version is 9.13.7; nobody migrating onto a current SDK is coming from a pre-4.0.0 install. -docs/PLAN_SDK_3_16.md/PLAN_SDK_3_17.md/PLAN_SDK_3_17_PROGRESS.md— internal planning documents, and this repo already has an establishedplans/PLAN.mdconvention (used byPLAN_PUSH_UPLOAD.mdetc.) that these predate and sit outside of. -docs/HANDOVER_MULTIHOP_PUBSUB_PROPAGATION.md— an internal incident investigation and handover document naming specific internal hosts and deployments (parksim-leuven, station names, live triage steps) — genuinely not public-facing material. -docs/ROADMAP.md— carried its own⚠️ OUTDATEDbanner and told readers to trustCHANGELOG.mdinstead; redundant with the file it was already deferring to. -docs/SOAK_2026-05-03_sanity.csv/.report.txt— raw output from one dated soak-test run, no narrative, not referenced from anywhere. -docs/ROADMAP.mdanddocs/migrations/V1_TO_V2_PUBSUB.mdwere both published to hexdocs (rebar.config'sextras) — removed those entries, plus every live cross-reference:docs/README.md(Quick Navigation row, the whole "Migrations" section, the Roadmap reference row),docs/guides/shared/CONNECTING_GUIDE.md,docs/guides/pubsub/PUBSUB_GUIDE.md(audience line + See also),src/pubsub/macula_pubsub.erl's moduledoc. Also removed a long-dead%% See architecture/ROADMAP.mdcomment inrebar.configpointing at a directory this repo has never had. -CHANGELOG.md's own prior entries mentioning these files are left untouched — historical record of what was true when written, per this project's standing convention, not live navigation. ### Notes - All removed files stay recoverable via git history; nothing here is destructive at the version-control level, only removed from the current tree and the published hex package / hexdocs site going forward. - Verified via a repo-wide grep after the edits: zero remaining live references to any removed path outsideCHANGELOG.md/CHANGELOG_LEGACY.md.rebar3 ex_docrebuilt clean (no missing-file errors, no orphaned pages);rebar3 compileclean; every remaining.md/anchor link in the repo (155 of them) re-checked against real rendered HTML. - No code changes — documentation andrebar.configonly, plus the one.erlmoduledoc comment trim inmacula_pubsub.erl. --- ## [9.13.6] - 2026-08-21 ### Changed (documentation) - **PUBSUB_GUIDE.md/PUBSUB_PROTOCOL.mdrestructured to match the Overview →Supervised wrappers: X / Y→ [pair content] →See alsoshape RPC/Content/Streaming already use.** Flagged directly: "why does PUBSUB_GUIDE have a different structure from the rest?" 9.13.5's split carried PubSub's pre-existing skeleton over unchanged (TL;DRinstead ofOverview, independent top-level## Subscribing/## Publishingsections each mixing wrapper usage with deeper protocol semantics) rather than conforming it to the template RPC established. Considered and rejected splitting into separatePUBLISH_GUIDE.md/SUBSCRIBE_GUIDE.mdfiles first — checked against the actual constraint ("shape must be the same"): a two-file PubSub would make its file topology diverge from RPC/Content/Streaming's one-Guide-one-Protocol shape, the exact inconsistency being removed, not a way to remove it. -PUBSUB_GUIDE.md:TL;DR→Overview(prose only, no inline code, matching the other three);## Subscribing+## Publishingmerged into one## Supervised wrappers: macula_subscriber / macula_publishersection holding only wrapper-usage mechanics (subscriber side, then publisher side — subscriber first because it's the long-lived, passively-waiting side, the same role Provider plays inmacula_response/macula_streamer/macula_feeder); the deeper protocol semantics that used to live inside those two sections — delivery ordering, dedup, delivery guarantees, subscription termination — promoted to their own top-level sections after it, in the slot RPC's## Errorsoccupies.Three core ideasmoved to directly followSupervised wrappers, same slot. - Corrected an accuracy gap surfaced while rewriting the wrapper intro paragraph: the old text impliedpubsub._v1mesh facts fire "around every operation" on both sides — checkedmacula_subscriber.erldirectly rather than assuming, and it publishes none at all. Onlymacula_publisherannounces (pubsub.publish_started_v1/pubsub.publish_completed_v1); a subscription has no single "done" moment to announce. Fixed in both files. -PUBSUB_PROTOCOL.mdreordered to match:Subscribingnow includes its own### Subscribing in a callback modulesubsection immediately (previously separated from it by the terminal-message content), andWhen the subscription endspromoted to a top-level section betweenSubscribingandPublishing, mirroring the Guide. - EveryX / Ywrapper-pair mention flipped frommacula_publisher / macula_subscribertomacula_subscriber / macula_publisherin both files, matching the "passive/long-lived side named first" convention already consistent across RPC (macula_response/macula_request), Content (macula_feeder/macula_download), and Streaming (macula_streamer/macula_stream_sink). - Confirmed via source (macula_publisher.erl,macula_subscriber.erl) that neither wrapper has astart_link_direct/advertise_directvariant — PubSub genuinely has no direct-dial mode, so (unlike the other three pairs) the restructured## Supervised wrapperssection has no### Direct-dialsubsection. Not an oversight; confirmed absence, not assumed. ### Notes - Zero content lost: every code block (20/20) and every table row (39/39) from the pre-restructure files is present verbatim in the restructured ones — scripted diff against the prior commit, not a visual skim. - Every anchor link touched by the reorder (11 cross-references between the two files, plus this file's own internal links) re-verified againstid="..."attributes in realrebar3 ex_docoutput, not assumed correct from the rename. - No code changes — documentation only. --- ## [9.13.5] - 2026-08-21 ### Changed (documentation — supersedes 9.13.4's reorder) - **Split each of the four primitive-pair guides into a daemon-facing Guide and a library-facing Protocol doc, and gavedocs/guides/real subdirectory structure.** 9.13.4 reordered sections within one file per guide so the supervised wrapper came first; that turned out not to be enough — three of four guides still *opened* (TL;DR/Overview) with raw code before the reordered wrapper section, because reordering moved a section without touching what greeted the reader first. The real fix is a hard split: a Guide that only ever showsmacula_request/macula_publisher/macula_feeder/macula_download/macula_pusher/macula_upload/macula_streamer/macula_stream_sink, and a Protocol doc holding every raw primitive, wire format, and internal-resolution detail the Guide used to carry. - New layout:docs/guides/{rpc,pubsub,content,streaming}/_GUIDE.md+_PROTOCOL.md,docs/guides/shared/_GUIDE.mdfor the five cross-cutting guides (Connecting, Topic Naming, Authorization, MRI, Records), andDIST_OVER_MESH_GUIDE.md/CLUSTERING_GUIDE.md/DEVELOPMENT.mdstaying flat atdocs/guides/(not primitive-pair material). All nine moves usedgit mvto preserve history. - **docs/guides/content/CONTENT_GUIDE.mdgained the "Push/upload:macula_pusher/macula_upload" section, moved wholesale out ofSTREAMING_GUIDE.md.** It'sclient_streammode with content's own integrity machinery bolted on, not really a streaming-primitives concern — it belongs next tomacula_feeder/macula_download, the wrappers it borrows that machinery from. - Each new_PROTOCOL.mdopens with the raw primitive walkthrough that used to open its Guide (e.g.macula:subscribe/5/publish/4,macula:call_stream_station/6), plus everything genuinely raw-only: MCID wire format, BOLT#4 error tables, DHT resolution internals,macula_content_transfer's real cancel/pause/resume/multi-stream API (none of whichmacula_feeder/macula_downloadexpose — confirmed by reading their source, not assumed), and local in-process streams. Confirmed which PubSub options genuinely reach throughmacula_subscriber/macula_publisher's ownOpts/Argsparameters by readingmacula_subscriber.erl/macula_publisher.erldirectly before deciding the split boundary —subscribe's delivery-ordering options pass through the wrapper,publish'stimeout_msdoes not. -rebar.configgainedgroups_for_extras, clustering the four Guides, four Protocols, and five Shared guides into their own hexdocs sidebar groups — mirrors 9.13.4'sgroups_for_modulesbut for the extras sidebar. Confirmed by inspectingdoc/dist/sidebar_items-.jsfor the actual group assignments, not just a clean build. -README.mdanddocs/README.md`'s documentation tables gained a row per new Protocol doc. - Found and fixed a real anchor-slug bug in 9.13.4's own cross-references while verifying this split's links against actual rendered HTML (not just file existence): ex_doc collapses ANY run of non-alphanumeric characters in a heading — an em dash, a slash, a colon, any combination — to exactly *one hyphen, not one hyphen per character removed.## Supervised wrappers: \macula_response` / `macula_request`slugs tosupervised-wrappers-macula_response-macula_request(one hyphen at the slash), not...-macula_response--macula_request(two) — 9.13.4's own RPC cross-references used the double-hyphen form and silently linked to a dead anchor on hexdocs, never caught because the build exits 0 and the href text looked plausible. Swept the wholedocs/tree for the same double-hyphen pattern and fixed all nine instances found (RPC, Content, Streaming, PubSub), then re-verified every anchor link in the repo (34 of them) againstid="..."` attributes in the real built HTML, not the crude markdown-only slug approximation used earlier in the session.
Notes
- Every split was verified content-preserving before commit, same method
as 9.13.4: a scripted heading-set and code-block diff between each
original file (via
git show HEAD:...) and the union of its resulting Guide + Protocol files. Zero headings or code blocks lost across all four splits — RPC (13→14 code blocks, +1 deliberate new example), PubSub (21→20, 3 raw→wrapper example rewrites accounted for), Content (16→16, only heading-level promotions), Streaming (13→13, only heading additions). - No code behavior changes — documentation and
rebar.configonly, plus two.erlmoduledoc comments (macula_topic.erl,macula_pubsub.erl) updated to point at the new guide paths.rebar3 compileandrebar3 eunitclean (1902/0 failed);rebar3 ex_docexit 0 with only pre-existing CHANGELOG.md autolink warnings, unrelated to this change.
[9.13.4] - 2026-08-21
Changed (documentation)
- The RPC, PubSub, Content, and Streaming guides now lead with the
supervised wrappers, not the raw wire primitives. Prompted by the design
question "devs will use the high-level stuff — why do the guides teach the
raw APIs first?" Checked the actual heading order rather than assuming:
three of four guides buried their
macula_response/macula_publisher/macula_streamer/macula_feeder-family sections 40-70% of the way through, after extensive raw-primitive walkthroughs, even though every guide's own> **Audience:**line says "applications" — not SDK contributors. Root cause: the raw primitives are original, the supervised behaviours arrived later in stages (macula_streamer/macula_feeder at 9.2.0, macula_publisher as late as 9.4.0, macula_pusher/macula_upload today), and each got appended as a trailing section rather than triggering a reorder.RPC_GUIDE.md— moved "Supervised wrappers: macula_response / macula_request" (plus its own direct-dial subsection) to right after Overview; the rawadvertise/call/call_stationwalkthroughs now follow, explicitly marked as "reach for this directly only if you're building something the wrapper doesn't fit."PUBSUB_GUIDE.md— moved "Subscribing with macula_subscriber (supervised)" and "Publishing with macula_publisher (supervised)" to immediately follow their raw call's signature, ahead of the deeper protocol mechanics (delivery ordering, dedup) and the hand-rolled gen_server pattern, which stayed in place with a "the raw pattern macula_subscriber wraps" framing note.STREAMING_GUIDE.md— moved "Supervised wrappers: macula_streamer / macula_stream_sink" (and the "Push/upload" section that was already correctly positioned right after it) ahead of "Consumer side"/"Provider side"'s rawcall_stream/advertise_streamwalkthroughs.CONTENT_GUIDE.md— already led with its wrapper section (added during this session's own PLAN_PUSH_UPLOAD.md work); only a short consistency callout added, matching the other three guides' framing.- Every heading's TEXT was left unchanged specifically to keep anchor IDs
stable — verified with a repo-wide scan for cross-references into these
four guides' sections (README.md, other guides,
.erlmoduledocs,plans/) before and after: zero external references existed to any of the moved sections, and the load-bearing internal ones were spot-checked against their real heading text after the move.
rebar.configgainedgroups_for_modules, clustering the 10 supervised-wrapper modules (macula_request,macula_response,macula_publisher,macula_subscriber,macula_feeder,macula_download,macula_pusher,macula_upload,macula_streamer,macula_stream_sink) under a "Supervised Wrappers" group on hexdocs' module sidebar — previously flat/alphabetical, with no signal distinguishing them frommacula_client/macula_station_link/macula_stream/macula_quicand the rest of the ~60 other modules an application almost never touches directly. Confirmed rendering by inspecting the builtdoc/dist/sidebar_items-*.jsoutput directly, not just a clean build exit code.
Notes
- A deliberate alternative NOT taken: physically splitting the wrappers
into a separate hex package (
macula-sdk) or amacula/sdk/subdirectory. This codebase has tried variants of that split twice before and reverted both times — a standalonemacula-sdkrepo (deprecated, folded back in) and amacula-v2umbrella of separate apps (fully absorbed as of 3.7.0). Checked why: the wrappers reach pastmacula.erl's facade into internal modules inconsistently even among themselves (macula_streamercallsmacula_streamdirectly;macula_stream_sinkgoes through themacula:facade for the same kind of call;macula_content_transfercallsmacula_station_linkandmacula_quicdirectly;macula_uploadIS amacula_streamercallback module, not just a caller of its public API) — there is no clean dependency line to cut without first hardening an internal public boundary, which is real, separate work, not a file move. Thegroups_for_moduleschange above gets the actual discoverability benefit without that risk. - No code behavior changes — documentation and
rebar.configonly. Full eunit suite unchanged at 1902/0 failed;rebar3 xref/dialyzerclean (same pre-existing warnings as before);rebar3 ex_docexit 0, no new warnings, confirmed by building and inspecting the actual generated output, not just the exit code, for both the module grouping and every reordered guide. - Every guide reorder was verified content-preserving before commit: every original heading still present exactly once, and every original code block byte-for-byte present somewhere in the new file (a scripted diff, not a visual skim) — matching this project's own "never delete features" rule applied to documentation, not just code.
[9.13.3] - 2026-08-21
Fixed (documentation)
- README.md's "Latest" banner was 11 minor versions stale (read
"9.2.0"; actual 9.13.2 at the time) and didn't mention
macula_content_transfer,macula_pusher/macula_upload, or any of the six PLAN_PUSH_UPLOAD.md phases shipped this session —macula_publisherwasn't mentioned in the README at all. Rewritten to describe the current state: every supervised primitive pair (RPC, pub/sub, content sharing, streaming RPC) complete and symmetric, each with a pooled and direct-dial mode, plus the new push-initiated transfer pair. Dependency-pin examples bumped~> 9.2→~> 9.13. STREAMING_GUIDE.md's "Push/upload" section had no diagram. Newassets/push_upload.svg, matching the visual language of the guide's other diagrams (content_streaming.svg,content_sharing.svg,rpc_two_stations.svg) — depicts the push (blue), the receiver's verify-then-reply (green), and the optional direct-dial resolve (purple dashed), plus the four-step flow from local manifest computation through the terminal reply.macula_streamer.erl's own moduledoc referenced a nonexistenthandle_open/3(four textual references, plus the module's own FIRST example using a three-argumenthandle_open/3clause head that would never match the real arity-2 callback) — a leftover from before the callback's real shape, never caught becauseSTREAMING_GUIDE.md's own example was always correct. Fixed all five, including rewriting the broken example to a genuinely arity-2, compilable shape.
Notes
- No code behavior changes — this release is documentation/assets only,
found and fixed during a release-readiness audit (see 9.13.2 for the
one real code bug that audit also found,
macula_response:advertise_direct/7). PATCH, not MINOR. - Full eunit suite unchanged at 1902/0 failed;
rebar3 ex_docconfirmed the new SVG builds intodoc/assets/push_upload.svgwith no new warnings.
[9.13.2] - 2026-08-21
Fixed
macula_response:advertise_direct/7now actually forwardsOptsto the underlying advertise call — it silently didn't before. Found while auditing release readiness against the identical bug just fixed inmacula_streamer:advertise_direct/7(9.13.0) — checked everyadvertise_direct/7implementation in the SDK for the same pattern and foundmacula_responsestill had it. It called the arity-5advertise/5(which always defaultsOptsto#{}) instead ofadvertise/6, soOpts => #{announce => false}— orauth, or ANY override — was silently discarded for every direct-dial-advertised RPC procedure, with no error anywhere to say so. Fixed at the source, same one-line shape as themacula_streamerfix (advertise(Pool, Realm, Procedure, Module, Args)→advertise(Pool, Realm, Procedure, Module, Args, Opts)), confirmed safe formacula_direct_dial:publish_advertisement/5(already ignores option keys it doesn't recognize).
Notes
- No public API changes — same signature, now honors
Optscorrectly. PATCH, not MINOR. test/macula_response_tests.erlgained a regression case (advertise_direct_forwards_opts_to_advertise), RED-verified against the reverted code before being confirmed to pass with the fix.- Confirmed via
grepthat no otheradvertise_direct/7implementation in the SDK has this bug —macula_streamer(fixed 9.13.0),macula_upload(correct from the start, forwards tomacula_streamer:advertise_direct/7), and nowmacula_responseare the only three, all correct.
[9.13.1] - 2026-08-21
Fixed
macula_mri_ets:related_to/2andrelated_from/2no longer defeat their own type specs for dialyzer. Both built theirets:match/2pattern via#rel_entry{...}record-construction syntax with ETS wildcard/capture atoms ('_','$1') in fields the record declares asbinary()/map()/integer()— e.g.object = '$1'whereobject :: binary(). Dialyzer checks a record construction against its own declared field types, finds no value can ever satisfy them, and concludes the function can never return normally (success typing is (_,_) -> none()), which then propagates into every caller:instances_of/1,classes_of/1,subclasses/1,superclasses/1, and transitivelyinstances_of_transitive/1— 7 warnings from 2 root-cause functions. Fixed by building the pattern as a plain tagged tuple in the record's own field order instead of via#rel_entry{...}— the exact same term at runtime (records ARE tagged tuples; this changes nothing about matching behavior, confirmed by the existingmacula_mri_ets_tests/macula_mri_graph_testssuites passing unmodified), just not run through dialyzer's record-field type check. RED-verified: revertingrelated_to/2alone reintroduced its own warning plus the two callers that depend solely on it (classes_of/1,superclasses/1), confirming both functions were independently necessary, not just one.
[9.13.0] - 2026-08-21
Added
macula_pusher/macula_upload— push-initiated content transfer. PLANPUSH_UPLOAD.md Phase 6, the plan's final phase.macula_pusher(sender) chunks and hashes bytes withmacula_manifest:create/2, opens aclient_streamto the recipient's advertised upload procedure with the manifest riding the stream's open-timeArgs(out-of-band, not an in-band header chunk), sends every chunk in order, and blocks for the recipient's own verified terminal reply before delivering `{ok, Mcid} | {error, }tohandlepushed/2.macula_upload(receiver) advertises the procedure, accumulates pushed chunks (built directly on Phase 5'shandle_chunk/2receive loop), and once the sender half-closes, reassembles and verifies withmacula_manifest:verify/2— receiver-side, never sender-trusted — before delivering{ok, Mcid, Bytes} | {error, }tohandleuploaded/2. Both publishsharing.pushv1/sharing.upload_v1mesh facts.start_link/start_link_directandadvertise/advertise_directrespectively — see the module docs' "correction from the plan's literal wording" sections for two places the plan's shorthand description didn't survive tracing the actual codebase: no multi-stream parallelism here (that's a content-sharing-only mechanism, per the plan's own scope-decision section — an earlier draft of the plan said otherwise), andmacula_upload's shape mirrorsmacula_streamer's (a long-lived, advertised provider), notmacula_download's (a one-shot, caller-initiated fetch) — the plan named the right API (advertise/advertise_direct`) but the wrong module to compare it to.macula_streamergained an optionalhandle_eof/1callback — aclient_streamprovider's one chance to set the stream's terminal reply (macula_stream:set_reply/2for{reply, {ok, Value}, State},set_error/2for{reply, {error, Reason}, State}) before it stops, called in place of the previous unconditional{stop, normal, State}on eof. A module that doesn't export it keeps the exact prior behavior. Needed to letmacula_uploadhandmacula_pushera verified outcome overclient_stream's own terminal-reply channel — the callback contract never exposes the raw stream pid to user code, so this had to live in the wrapper itself, at the one point (handle_info(stream_eof, State)) that still has it.
Fixed
macula_streamer:advertise_direct/7now actually forwardsOptsto the underlying advertise call — it silently didn't before. Found while buildingmacula_upload's direct-dial path, not something either intentionally relied onmodebeing ignored. It called the arity-5advertise/5(which always defaultsmodetoserver_stream) instead ofadvertise/6, soOpts => #{mode => client_stream}— or ANYmode/announceoverride — was silently discarded for every direct-dial advertisement, not just this session's. Aclient_streamprovider that advertised directly would have been served asserver_streaminstead, with no error anywhere to say so. Fixed at the source, per this project's "fix bugs in owned libraries immediately" rule.
Notes
- No public API changes to already-shipped modules beyond the new
optional
handle_eof/1callback (additive) and theadvertise_direct/7bug fix (same signature, now honorsOptscorrectly) — MINOR, not MAJOR. - New test files:
test/macula_pusher_tests.erl(7 cases),test/macula_upload_tests.erl(5 cases, including a genuine receiver-side-verification-catches-tampering case and the too-many-chunks guard),test/macula_streamer_eof_reply_tests.erl(2 cases).test/macula_streamer_tests.erlgained a regression case for theadvertise_direct/7fix. - A design detail worth recording:
macula_upload'shandle_open/2does NOT reject a manifest that fails to decode via{stop, Reason, State}— traced why that would be wrong: ahandle_open/2stop makes the underlyingmacula_streamer:init/1itself return{stop, Reason}, a genuine gen_server init failure, and OTP never callsterminate/2for a process that failed to start. That would have silently dropped the push (nohandle_uploaded/2, nosharing.upload_completed_v1) and left the sender's ownmacula:await_reply/1hanging or crashing, since nothing ever reacheshandle_eof/1to set a reply either. Accepting the stream and stashing the decode error instead letshandle_eof/1— the one place already wired to set a terminal reply — report it correctly on both sides once the sender closes, exactly like any other failure. Caught by actually running the test before assuming the simpler design would work, not by reasoning it through in the abstract.
[9.12.0] - 2026-08-21
Added
macula_streamernow supportsclient_streammode: an optionalhandle_chunk/2callback drives a linked-readerrecv/2loop on the provider side, mirroringmacula_stream_sink's consumer-side callback of the same name. PLAN_PUSH_UPLOAD.md Phase 5. Before this,macula_streameronly wrappedsend/2,3/close/1— it fitserver_stream(provider pushes) but had no receive path at all for a provider that needs to receive pushed chunks (a batch upload,client_stream's whole reason for existing perSTREAMING_GUIDE.md). Aserver_stream-mode module that doesn't exporthandle_chunk/2is unaffected — the reader is only spawned when the callback is present, gated byerlang:function_exported/3, the same mechanismmacula_stream_sink's optionalhandle_close/2already uses.
Fixed
macula_streamer/macula_stream_sinknow send the peer a genuinemacula_stream:abort/3STREAM_ERROR on any non-normaltermination, instead of an ordinary close (sink) or nothing at all (streamer). Same bug class as Phase 1's content-transfer cancel fix, applied here: before,macula_stream_sink:terminate/2calledmacula:close_stream/1unconditionally, regardless ofReason— so a real failure (arecverror, the reader crashing) looked to the peer exactly like a clean end-of-stream, not a cancellation.macula_streamer:terminate/2was worse: it never closed or aborted its underlyingmacula_streamat all — a graceful stop (Reason = normal) orphaned that process forever (the link only propagates a non-normal exit to a non-trapping peer, andmacula_stream's own auto-stop is tied to itsowner, an internal station-link stub process, not tomacula_streamer), and an abnormal stop killed it via the ordinary link-crash cascade with no explicit protocol-level signal ever reaching the far side. Both modules now close cleanly (macula_stream:close/1) on anormalreason and abort (<<"cancelled">>code, the reason folded into the message) on anything else — the peer can now genuinely tell a cancellation/failure from an ordinary end-of-stream, for both roles.
Notes
- No public API changes to
macula_stream_sink(no new exports). New optionalhandle_chunk/2callback onmacula_streamer— additive, so MINOR not MAJOR; bundled with the abort-wiring fix in the same release since both were the same phase's deliverable and neither changes any existing function signature. - No pause primitive added for streaming RPC — per the plan's own scope
decision (see PLAN_PUSH_UPLOAD.md): a QUIC stream is reliable and
ordered, so a consumer that stops calling
recv/2already backpressures the sender via QUIC's own flow control. Re-litigate only if a concrete need surfaces. - New test file
test/macula_streamer_client_stream_tests.erl— split out frommacula_streamer_tests.erlbecause it needs a callback module that genuinely exportshandle_chunk/2; that file's existing callback module deliberately does not (exporting it there would spawn a reader for every one of ITS tests too, sincefunction_exported/3gating is module-wide, not per-test-case). Same reasoning Phase 3 used to splitmacula_content_transfer_multi_stream_tests.erlout for a similarly distinct-mock-shape need.
[9.11.1] - 2026-08-21
Fixed
macula_feeder/macula_download'scancel/1now reaches the real, underlying transfer — previously it orphaned it. PLAN_PUSH_UPLOAD.md Phase 4. Both modules used to run a blockingmacula:put_content/2/get_content/2call in a linked worker;cancel/1(gen_server:stop/1) could only kill that local worker, never themacula_content_transferit was blocked insideawait/1on. Nothing links agen_server:callcaller's death to the callee, so a cancelled transfer kept running to completion — or, once resolved, sat alive forever, never reaped, leaking itscontent_stream_bufsentry on the link and itsmacula_content_transfer_registryentry for no purpose. Both modules now callmacula_content_transfer:start_put/3(orstart_get/3,start_put_station/5,start_get_station/5for direct-dial) directly from a lightweight resolve + await proxy, hold the resulting pid in their own state, andterminate/2cancels it for real — the same peer-visible QUIC RESET_STREAM abortmacula_content_transfer:cancel/1always gave a direct caller, not a local kill with nothing downstream the wiser. Theshare_ideach module already minted for its ownsharing.*mesh facts is threaded through asmacula_content_transfer's ownshare_idtoo, so both layers resolve to the same id.- Direct-dial (
start_link_direct/5,6/start_link_direct/4,5) gets the same real-cancel fix — the resolve step (macula_direct_dial: resolve_station_endpoint/2/resolve_content_provider/2) stays a plain blocking DHT lookup exactly as before (nothing has ever needed to cancel mid-resolve), but the transfer itself is now addressable the same way pooled mode's is.
Notes
- No public API changes — same
start_link/4,5,start_link_direct/5,6(feeder) /start_link_direct/4,5(download),cancel/1, sameinit/1/handle_fed/2/handle_downloaded/2callback contract. PATCH, not MINOR: this fixes existing behavior rather than adding capability. macula_feeder_tests.erl/macula_download_tests.erlnow mock at themacula_client/macula_station_linkboundary (the same layermacula_content_transfer_testsmocks) instead ofmacula:put_content/2/get_content/2directly — a mechanical necessity, not a design choice: the internals no longer call those functions at all, so the old mocks would simply never fire.Poolis now a real pid in these tests (it's threaded down tomacula_content_transfer:start_put/3's ownis_pidguard), not the placeholder atompoolthe pre-Phase-4 suite used. Two new cases per module cover what Phase 4 actually fixes (assertingabort_content_streamis genuinely called on cancel, not just that the feeder/download reportsoutcome => cancelled) and direct-dial (previously untested).
[9.11.0] - 2026-08-21
Added
- Multi-stream parallel chunk transfer for chunked content.
PLANPUSH_UPLOAD.md Phase 3. Chunks are distributed round-robin
(
Index rem StreamCount) across up tostream_countdedicated content streams on the same link (Opts'sstream_countkey, default 4, always capped at the actual chunk count) — each stream runs its own independent chunk-by-chunk loop concurrently, all driven by the ONEmacula_content_transfergen_server viahandle_continue/2(never by the streams' own one-call-and-report worker processes). The manifest is put (or, for a get, its chunks reassembled — by chunk INDEX, not arrival order, since different streams finish in whatever order their own network calls happen to complete in — and verified) only once every stream has drained its own share. A get doesn't know the chunk count, and therefore how many streams are worth opening, until its manifest is fetched, so it starts on the one stream the connect step already opened and expands once the count is known; a put knows upfront and opens every extra stream immediately. Opening an extra stream is best-effort — a failure degrades to fewer streams rather than failing the transfer (a single-stream transfer is still correct, just slower). A single stream's own chunk genuinely failing (an `{error, }, not a crash) fails the whole transfer exactly as a sequential one would: every other stream's in-flight work is killed and every stream reset beforeawait/1,2` sees the error. pause/1/resume/1(9.10.0) now gate every open stream uniformly, not just one — the samepausedcheck, in the same place, whether there's one lane or several.cancel/1,3(9.9.0) now resets every currently-open content stream on cancellation, not just one.
Changed
macula_content_transfer's single-stream chunk-loop internals (9.10.0'sdispatch_next_step/step_result/etc., single#chunkfieldsremaining/next_index/acc) are replaced by a per-stream#lane{}model — each stream owns its own remaining-work queue, in-flight item, and worker. Single-block put/get is untouched — still one worker, connect through completion, exactly as 9.9.0 shipped it; there is no "another stream" for a one-round-trip transfer to use.
[9.10.0] - 2026-08-21
Added
macula_content_transfer:pause/1/resume/1— real pause/resume for chunked put/get. PLAN_PUSH_UPLOAD.md Phase 2. The per-chunk step loop (put a chunk / get a chunk / put or get the manifest) now runs as ahandle_continue/2step the gen_server re-triggers itself between chunks, checking apausedflag each time —pause/1stops the loop from advancing to the next chunk (the chunk already in flight, if any, still completes uninterrupted — its own round trip stays one blocking call, matching content's existing "a chunk is verified whole or not at all" model);resume/1re-arms it from exactly the next un-sent/ un-fetched chunk, never from the start. Single-block content has no "between chunks" to pause at, sopause/1there is a harmless no-op — the transfer just runs to completion regardless.- Each chunk step (one
_content.put_block/get_block, or the manifest's_content.put_manifest/get_manifest) now runs in its own short-lived linked worker rather than inside one long recursive loop, socancel/1,3can always kill whichever step is currently in flight — same guarantee Phase 1 gave the whole transfer, now granular to each chunk. Closed a real gap this uncovered: cancelling while genuinely paused between chunks means no step worker is alive at all (worker = undefined), which the Phase 1 cancel path didn't handle — fixed (kill_worker/1now treatsundefinedas nothing to kill, verified RED before GREEN: reverting the fix reproduces the exact{badarg, [{erlang,unlink,[undefined]...crash it prevents).
Changed
macula_content_transfer's internal chunk-loop functions (put_chunks/chunk_put_result/put_manifest/get_chunks/chunk_get_result/etc., moved verbatim frommacula.erlin 9.9.0) are replaced by the step-driven design above. Single-block put/get is untouched — still one worker, connect through completion, exactly as 9.9.0 shipped it; there is no "between chunks" for it to participate in.
[9.9.0] - 2026-08-21
Added
macula_content_transfer— addressable content-store put/get with a real, peer-visible cancel. PLAN_PUSH_UPLOAD.md Phase 1.macula:put_content/2/get_content/2(and the_stationvariants) were one opaque blocking call each: pick a link, open a dedicated content stream, run the transfer, close it — no handle existed mid-transfer, so cancelling meant killing whatever process was blocked in the call, which never touched the stream itself (macula_station_linkowns it, not the killed caller) — leakingcontent_stream_bufs/content_pendingstate on the link until the nextcontent_call_timeoutfired against an already-dead caller.start_put/2,3,start_put_station/4,5,start_get/2,3,start_get_station/4,5return{ok, Pid}immediately;await/1,2blocks for the outcome;cancel/1,3tears the transfer down from any point in its lifecycle, resetting the open stream if one exists.put_content/2/get_content/2(+_stationvariants) are now thin blocking wrappers over this — same public signature, no caller changes needed (verified: no direct callers in macula-station, macula-realm, or hecate-om).macula_quic:reset_stream/2— a genuine QUIC RESET_STREAM abort, new Rust NIF (nif_reset_stream, Quinn'sSendStream::reset). Content-transfer'scancel/3needed a real, peer-visible signal —macula_stream:abort/3(streaming RPC's abort) doesn't apply here, it targets amacula_streamgen_server's own STREAM_ERROR framing, and a content-transfer stream is a raw QUIC dedicated stream with no such process. The peer'sRecvStream::readnow distinguishes a reset from every other read failure:{quic, stream_closed, PeerStream, {reset, ErrorCode}}instead of the same undifferentiatednonereason every read error used to collapse into. Along the way, fixed a real pre-existing stub:async_shutdown_stream/3has taken(Stream, Flag, Code)since this module's msquic-era design but silently discardedCodeand always did a gracefulclose_stream/1— it now genuinely resets withCode(zero callers anywhere in macula-station/macula-realm/hecate-om, confirmed before changing its behavior).macula_station_link:abort_content_stream/4— the real-abort counterpart toclose_content_stream/2, used bymacula_content_transfer:cancel/3.macula_content_transfer_registry— correlation-id → pid lookup for content transfers (ETS-backed, monitor-based cleanup), so a caller that only knows a transfer'sshare_id(from a publishedsharing.*_started_v1mesh fact) can still resolve it tocancel/1,3.
[9.8.2] - 2026-08-21
Fixed
macula_client:connect/2with noidentityopt now defaults to a puzzle-hardened identity, not a plain one (macula_client.erl,init/1+ newresolve_identity/1). Stations may runpuzzle_enforcement: enforce(S/Kademlia identity puzzle, rejects any peer whoseSHA-256(pubkey)lacks the configured leading-zero bits). A caller who didn't passidentityused to getmacula_identity:generate()— no puzzle grind — which fails that check. The failure is silent by construction: the QUIC/TLS transport still reports the link healthy, andsubscribe/5still returns{ok, _}locally, because both succeed before the station's handshake rejection closes the connection. Confirmed live: a production consumer (macula-realm'sMaculaRealm.Mesh, connecting with%{}opts) sat fully connected-looking with zero events delivered on any subscription, across all 5 of its station links, for over an hour, before the identity itself turned out to be the cause.resolve_identity/1only changes the pool's own default identity resolution; it does not touchmacula_identity:generate/0itself (still documented as "does not grind a puzzle" — callers who want a plain identity can still ask for one directly), and it's lazy where the oldmaps:get/3call was not (that evaluated its default argument, and therefore generated a throwaway keypair, on every singleconnect/2call regardless of whether the caller passed an identity).
[9.8.1] - 2026-08-21
Fixed
- Peer-initiated dedicated stream: notify before enabling active
delivery, not after (
macula_peering_conn.erl,connected/3's{quic, new_stream, ...}clause). A new stream resource is created passive (StreamResource::active: AtomicBool::new(false)in the Rust NIF; its recv loop blocks on aNotifyuntilsetopt(active, true)wakes it), so nothing can be delivered tocontrolling_pidbefore that NIF call runs — except the old code calledsetoptbefore sending the{macula_peering, new_dedicated_stream, ...}notification. On a fast/near-zero-RTT path the peer's first frame (sent the instant it finishes opening the stream) could then reachcontrolling_pid's mailbox before the notification did, and every dedicated-stream consumer keys its buffer off that notification (stream_bufs/content_stream_bufs), so the data landed nowhere and was silently dropped by whichever catch-all the consumer had. Reordering the two calls closes the race structurally: passive mode guarantees zero delivery untilsetoptruns, and by then the notification is already in the mailbox. Found via macula-station's cross-station streaming-RPC relay (a station relaying a STREAM_OPEN onto the next hop by opening a fresh dedicated stream), where it surfaced as a deterministic-then-intermittent timeout depending on which side of the relay owned the connection; local reproduction went from 100% failure (pre-existing bug this uncovered, see below) to 0% deterministic / ~11% intermittent after this fix alone. A second, independent bug on the macula-station side (outbound_link had no handling for the notification at all) accounted for the rest of the original 100% failure rate and is fixed separately, in macula-station.
[9.8.0] - 2026-08-20
Added
- Direct-dial for streaming RPC (
macula_stream_sink:start_link_direct/5,6,macula_streamer:advertise_direct/6,7,macula_direct_dial:call_stream/5,6). The streaming counterpart to RPC direct-dial — and it turns out to need almost no new machinery: aprocedure_advertisement' does not distinguish RPC from streaming, only the eventual dial (call_station/7' vscall_stream_station/6') does, sopublish_advertisement/4,5' andresolve_dial_url/4' are reused as-is by both.macula_streamer:advertise_direct/6,7' isadvertise/5,6' plus the identical publish stepmacula_response:advertise_direct/6,7' already does for plain RPC.
Fixed
macula_client:call_stream_station/6now threads the per-call TLS trust override (verify/expected_node_id/pin_tls_cert) through to the underlying dial, same ascall_station/8— it predated that work and had no way to specify per-call trust at all, so a fresh dial from it always fell back to the pool's connect-time defaults. Against production this had the same TLS-pin problem the RPC family had beforepin_tls_certexisted; now it can actually be direct-dialed. Removedmacula_client:ensure_link/2, left unused once its only remaining caller moved to the 3-arity form that carriesLinkOpts.
[9.7.0] - 2026-08-20
Added
- Direct-dial for content upload (
macula_feeder:start_link_direct/5,6,macula_direct_dial:put_content/4,resolve_station_endpoint/2,macula:put_content_station/4,5). The PUT-side counterpart to download direct-dial. Unlike a GET, a PUT has no discovery step — the caller already knows (or is choosing) which station to seed, so it namesStation' directly rather than resolving one from an announcement. Reuses the exactstation_endpoint' resolve machinery RPC direct-dial already built forserving_station' (same signer check, same stale-record retry), just exposed as a standalone publicresolve_station_endpoint/2'. macula:put_content_station/4,5— the content-transfer counterpart toget_content_station/4,5, symmetric in shape.
[9.6.0] - 2026-08-20
Added
- Direct-dial for content download (
macula_download:start_link_direct/4,5,macula_direct_dial:get_content/3,macula:get_content_station/4,5,macula_client:ensure_content_link/4). Resolves a chunked MCID's provider from its signedcontent_announcement(published automatically by the provider's station on receipt — nothing new to advertise, nomacula_feeder-side change needed) and dials that station directly for the fetch, instead of depending on the caller's own station being able to reach it via relay. Content's trust model is deliberately lighter than RPC's direct-dial: content is content-addressed and independently re-hashed client-side regardless of which peer serves it, so there is no cert-chain-equivalent opt here — seemacula_direct_dial's module doc, "Content" section, for why. macula:get_content_station/4,5— the content-transfer counterpart tocall_station/6,7: dial a specific, already-resolved station directly for aput_content/get_content-shaped dedicated- stream transfer, with the same per-callverify/expected_node_id/pin_tls_certtrust override.
Fixed
find_content_providers/2now checks the announcement's signer against its own claimedannouncer_node, not just that SOME valid signature is present. The publiccontent_announcement/3,4constructor always keeps the two consistent, so this could only diverge via a hand-crafted record — exactly the malicious/non-SDK publisher case the check exists for. Same class of fix asmacula_direct_dial:verify_and_build/2already applies tostation_endpoint.- Single-block
get_content/2now verifies the fetched bytes' BLAKE3 hash against the MCID client-side. Chunked content already got this viamacula_manifest:verify/2over the reassembled whole; single-block content had no client-side check at all, relying entirely on whichever station served the request having verified it once, at PUT time — not necessarily the station being fetched FROM, especially onceget_content_station/5lets a caller deliberately dial a resolved, third-party provider.
[9.5.0] - 2026-08-20
Added
- Direct-dial for RPC (
macula_direct_dial,macula_request:start_link_direct/6,7,8,macula_response:advertise_direct/6,7,macula_client:call_station/8,macula:call_station/7). An alternative to the gossip-routedmacula:call/5path: the caller resolves a procedure'sprocedure_advertisementfrom the DHT, resolves and verifies that advertisement'sserving_stationto a dialablestation_endpointrecord, and dials it directly over one QUIC hop instead of depending on advertise-gossip having propagated a route between arbitrary stations. pin_tls_certconnect/link opt (macula_peering_conn,macula_station_link,macula_client:call_station/8,macula:call_station/7). Decouplesexpected_node_id's two enforcement points, previously fused: pinning the QUIC/TLS certificate's own SPKI (pin_tls_cert => true, the default — correct only when the peer's TLS cert genuinely IS its macula identity, e.g. self-signed test clusters) versus the application- layer CONNECT/HELLO signature check (bind_peer_identity/2, always enforced whenexpected_node_idis set, regardless ofpin_tls_cert).pin_tls_cert => falseis required to direct-dial any station whose TLS is terminated by a PKI unrelated to its macula identity — a production station behind Let's Encrypt, for instance, where the cert's key has no relationship to the station's Ed25519 identity and the pin can never succeed. Trust for such a dial rests entirely on the signed HELLO handshake instead, checked against the same pubkey the DHT chain resolved. Direct-dial (macula_direct_dial) always dials this way.- Mandatory advertisement signature verification + opt-in cert-chain
check for direct-dial (
macula_direct_dial). Resolving aprocedure_advertisementnow discards any candidate record that fails Ed25519 signature verification before trusting itsserving_stationat all — previously the first DHT record found was trusted unconditionally, so any identity able to sign SOME record could point a caller at a real, legitimate station it had no authority to name.call/6andpublish_advertisement/5additionally accept an opt-inverify_cert_chain => {RealmCaPem, Org}/cert_chain => ChainPempair (managed realms only) that requires the advertisement's embedded X.509 service-cert chain to verify to the realm CA under the given org (Slice 7c Direction B, via the existingmacula_record:verify_advertisement_cert_chain/3), proving the advertiser itself — not just the station it names — is an org/realm-authorized identity.
[9.4.0] - 2026-08-20
Added
macula_publisher— the missing supervised behaviour for pubsub publishers. Every other primitive pair already had a supervised behaviour on both sides (macula_request/macula_responsefor RPC,macula_streamer/macula_stream_sinkfor streaming RPC,macula_feeder/macula_downloadfor content sharing); pubsub had only the consumer side (macula_subscriber).macula:publish/4remains a plain blocking call with no addressable pid to cancel or observe from outside.macula_publisher:start_link/5,6returns immediately with a pid, runs the publish in a linked worker, delivers the outcome toModule:handle_published/2, and publishespubsub.publish_started_v1/pubsub.publish_completed_v1mesh facts around the transfer — mirroringmacula_feeder's shape exactly.
[9.3.1] - 2026-08-20
Fixed
STREAMING_GUIDE.md's dedicated-stream claim (accurate since 9.3.0) was worded ambiguously enough to still read as the old shared-control-stream behavior it was meant to have moved past. States explicitly now that each streaming session gets its own QUIC stream, not multiplexed onto the connection's shared stream the way an ordinary CALL or PUBLISH is. Docs-only; no code changes.
[9.3.0] - 2026-08-20
Streaming RPC and content transfer now genuinely ride their own dedicated QUIC
streams, closing a real gap between the docs and the implementation. Prior
releases' STREAMING_GUIDE.md claimed RPC, PubSub, streaming, and content each
used "independent multiplexed QUIC streams" with "per-stream flow control" — that
was false. macula_peering_conn.erl opened exactly one QUIC stream per peering
connection, and every frame type (CALL, RESULT, PUBLISH, STREAM_OPEN, content
put/get blocks, ...) was multiplexed onto it via application-level IDs. QUIC's
actual per-stream isolation was unused. This release wires the two workloads
where it matters most — streaming and content — onto real, separate QUIC streams.
See plans/PLAN_PER_STREAM_QUIC_ISOLATION.md for the full design record.
Added
- Streaming RPC dedicated streams.
advertise_stream/call_streamsessions each get their own QUIC stream, opened via newmacula_peering: open_dedicated_stream/1andsend_on_stream/3primitives. STREAM_OPEN/DATA/ END/ERROR/REPLY no longer travel the shared control stream. - Content transfer dedicated streams.
put_content/get_content(both single-block and chunked) now pin one healthy pool link (macula_client:pick_connected_link/1, new) and run the whole transfer's block + manifest calls over one dedicated stream (macula_station_link:open_content_stream/1/call_on_stream/6/close_content_stream/2, new), instead of letting the pool re-pick a link per underlying CALL. A large blob transfer no longer head-of-line-blocks other RPC/PubSub traffic on the same connection. CONTENT_GUIDE.mddocuments the new per-transfer stream isolation.
Fixed
macula_peering_conn.erl: the client role never started the QUIC bidi- stream accept loop (macula_quic:async_accept_stream/1), only the server role did. Harmless in the pre-dedicated-stream world (a client only ever opened the one stream it used itself), but meant any client-role connection — a daemon dialing a station, or a station dialing another station — could open a dedicated stream outward but could never receive one opened at it. Found via live testing (macula_station_call_stream_station_SUITEinmacula-station), not by inspection: the failure mode was total silence, since nothing errors when a peer simply never callsaccept_bi().macula_station_link.erl:fail_all_pending/2still pattern-matched the pre-dedicated-stream 2-tuple shape ({Pid, Mon}) forclient_streams/server_streamsentries, which had already grown a third element (the dedicated stream reference). Every disconnect would have crashed the link's gen_server instead of cleanly aborting open streams.
[9.2.0] - 2026-08-20
A supervised, fact-announcing primitive family sits on top of the four raw
mesh operations. Every one of advertise/5, call/5, advertise_stream/5
/ call_stream/5, put_content/2 / get_content/2, and subscribe/5
already spawns or blocks a bare process with no addressable pid — nothing to
cancel, nothing to supervise, nothing to observe from outside. This release
adds four symmetric provider/consumer pairs, each a proper gen_server
behaviour with a simple_one_for_one factory supervisor, publishing mesh
protocol facts around its own side of the operation:
Added
macula_feeder/macula_download— supervised wrappers aroundput_content/2/get_content/2. Publishsharing.put_started_v1/sharing.put_completed_v1andsharing.get_started_v1/sharing.get_completed_v1, carryingchunked => true | false. Replaces the unreleased, unpublishedmacula_content_sharing(deleted — nothing depended on it outside this repo).macula_streamer/macula_stream_sink— supervised wrappers aroundadvertise_stream/5/call_stream/5. Publishstreaming.started_v1/streaming.completed_v1from each side independently.macula_streameris push-based:Module:handle_open/2registersself()however the application discovers it, then any process holding that pid drives the stream viamacula_streamer:send/2,3/close/1.macula_response/macula_request— supervised wrappers aroundadvertise/5/call/5. Publishrpc.received_v1/rpc.replied_v1(provider) andrpc.sent_v1/rpc.completed_v1(consumer, includingoutcome => cancelledwhen cancelled before a reply arrives).macula_subscriber— supervised wrapper aroundsubscribe/5, threadingmacula_event/macula_event_gonedispatch intoModule:handle_event/4.- Every pair has a
_supfactory (macula_feeder_sup,macula_download_sup,macula_streamer_sup,macula_response_sup,macula_request_sup): provider-side ones are started internally byadvertise/5,6and hidden from the caller; consumer-side ones are meant to be embedded in the caller's own supervision tree, so acancel_*command becomessupervisor:terminate_child/2(orcancel/1on the child pid directly) against a child the application already owns. RPC_GUIDE.md,STREAMING_GUIDE.md,CONTENT_GUIDE.md, andPUBSUB_GUIDE.mdeach gained a section introducing their pair, plus aReference/See alsorow.
Fixed
mcid/0was used in three exported-specs (put_content/2,get_content/2,find_content_providers/2) but never in-export_type— any external consumer's dialyzer run saw an unknown type. Now exported.macula.app.src'slinksentry was labeled"GitHub"but pointed atcodeberg.org— a leftover from the pre-2026-07-26 hosting arrangement. Now points atgithub.com/macula-io/macula.
No breaking changes — every addition is a new module; nothing existing changed shape.
[9.1.1] - 2026-08-20
Every guide and the README, checked line-by-line against real source — not
a proofread, an audit. 9.1.0 already fixed one stale README hero diagram;
publishing it surfaced a live ~> 8.8 dependency pin in the README's own
install snippet, which turned into a full sweep of every published guide.
The scope kept growing because the method kept finding real bugs: for every
function call, grep the real -export() list and arity; for every "the SDK
does X automatically" claim, grep for the actual call site rather than
trust the prose; for every return-shape example, read the function body,
not just its -spec (several were generic term() hiding a more specific
real shape). No runtime behavior changed anywhere in this release — every
fix is documentation catching up to code that was already correct.
Fixed — fabricated or superseded content removed
RPC_GUIDE.mdfully rewritten. Described a nonexistent legacy API (advertise/3returning{ok, Ref},unadvertise/2,call/3) and a "Call Flow" matching WAMP/Bondy-era relay-routed RPC withgproclocal lookup — contradicting the guide's own callout that direct-dial is current. Rewritten against the real facade, including the handler-return contract traced frommacula_station_link:safe_invoke_handler/4's actual body and a verifiedcall_station/6resolve-then-dial recipe.AUTHORIZATION_GUIDE.mdinvented four entire modules with zero code backing them (macula_did_cache,macula_authorization,macula_ucan_revocation,macula_authorization_audit) — DID-namespace gating, automatic.public.topic gating, UCAN revocation with a fabricated rate limit, audit logging with a fabricated LRU policy. None of it is real; the SDK's only enforced authorization isadvertise/5's per-procedure{ucan_required, Issuer}. Rewritten to only what's real. A second independent pass caught two more return-shape bugs:encode/1andto_map/1are barebinary()/map(), not{ok, _}-wrapped.PROTOCOL_GATEKEEPER_GUIDE.mddeleted. Its entire premise — amacula_protocolbehaviour, amacula_gatekeepervalidator, a Portal/Console certificate hierarchy — belongs to the old, archivedmacula-console/macula-portalproduct, not this SDK.PUBSUB_GUIDE.mddescribed the pre-8.8.0 ordering bug as current behavior ("no per-publisher order... dozens of inverted pairs"), contradicting the correctordered-by-default content earlier in the same file. Also claimed a dead link sends{macula_event_gone, SubRef, {disconnected, _}}; the real behavior is a silent respawn + resubscribe, never an event.DIST_OVER_MESH_GUIDE.md:join_mesh/1's options table claimedrealmandtls_verifyoptions that don't exist (onlyrelaysandidentityare real);DIST_TIMEOUTwas documented as 25000ms, the real value is 10000ms.CLUSTERING_GUIDE.md: themdnsstrategy was documented as doing real mDNS/Bonjour discovery; it's accepted and logged but never branched on —mdnsanddhtcurrently do the identical DHT-based thing. An "Integration with bc_gitops" section referenced an unrelated external project, same contamination pattern found inAUTHORIZATION_GUIDE.md.CONNECTING_GUIDE.md,TOPIC_NAMING_GUIDE.md,DEVELOPMENT.md,docs/GLOSSARY.md, anddocs/README.mdall referenced a phantommacula_mesh_clientmodule as current behavior — it's real, but v2.1.0-era history, superseded by the V2 pool (macula_client) well before 3.11.0.TOPIC_NAMING_GUIDE.mdalso falsely claimed topic validation is enforced automatically at publish/subscribe (it isn't —macula_topic:validate/1is never called on the SDK's send path) and had a fabricated "Wildcard Subscriptions" section with no supporting code anywhere.- Ten SVGs deleted, each depicting one of the above rather than being
merely unreferenced:
mesh-architecture.svg,pubsub_flow.svg,rpc_flow.svg,revocation_flow.svg,audit_system.svg,lru_eviction.svg,namespace_hierarchy.svg,gatekeeper_security_model.svg,protocol_callbacks.svg,gatekeeper_flow.svg,cluster_integration.svg,relay_failover.svg. Three more still-embedded SVGs (connect_flow.svg,dist_over_mesh.svg,mri-architecture.svg) had the same phantom module or a wrong function name and were fixed in place rather than removed. - README.md: the
rebar.config/mix.exsinstall snippets pinned{macula, "~> 8.8"}— the actual code a new user copies, resolving to the 8.8.x line, not this package's current version. Also trimmed ~150 lines that duplicated guides verbatim (the Four Interaction Patterns walkthroughs, Distribution, Clustering, MRI sections) down to the capability list + guide table that already did this job — the duplication is exactly why the stale hero diagram and the dependency pin went unnoticed for as long as they did.
Added
docs/guides/RECORDS_GUIDE.md— the raw DHT record API (put_record/2,find_records_by_type/2,macula_record:envelope/4for a custom record type) was exported, public API with no guide.
Clarified
- Seed URL scheme (
quic://vshttps://) is a label, not a switch —macula_station_link:parse_seed/1dials over QUIC regardless of the scheme text, and both are genuinely seen in production. Documented instead of silently picking one.
[9.1.0] - 2026-08-20
OTP 29 readiness, plus a records guide and a stale-docs cleanup.
Added
docs/guides/RECORDS_GUIDE.md. The raw DHT record API (put_record/2,find_record/2,find_records/2,find_records_by_type/2,subscribe_records/3,unsubscribe_records/2, andmacula_record:envelope/4for defining your own record type in the0x20-0xFFtag range) was public, exported API with no guide — one line in a CONTENT_GUIDE comparison table was the only mention. Documents storage-key derivation (includingsubject_id) and the built-in-vs-domain-defined type split.MRI_GUIDE.mdnow embedsmri_trie_index.svg(previously README-only) next to thebuild_index/1/index_children/3example it illustrates.
Changed
assets/sdk_architecture.svgregenerated. The README's hero diagram still said "Macula SDK v1.0.0 — 48 Modules" and named modules that no longer exist (macula_mesh_client,macula_multi_relay,macula_local_client) — current is 9.x with 89 modules across a vertical-sliced tree that's been reorganized several times since that image was drawn. A module-inventory diagram tied to internal layout drifts every time a slice gets renamed or moved, which is often by design in this codebase. Replaced with a capability map keyed to the public facade and the guide table instead (PubSub, RPC, Content, Records, Streaming, Distribution over Mesh, Clustering, Authorization, MRI, over an Identity/Crypto + Wire Protocol substrate) — no module count, no version number baked into the image, so it can't go stale the same way. Restyled to match the light theme already used by the four interaction diagrams instead of its previous, inconsistent dark theme.
Fixed
- 132 bare
catch Exprsites rewritten totry Expr catch _:_ -> ok endacross 41 files. OTP 29 deprecates the bare form; combined with this project'swarnings_as_errors, it was a hard compile failure. 6 sites relied oncatch's special{'EXIT', Reason}return value being pattern-matched by the caller (hex/port/binary decoding inmacula_uri,macula_cert,macula_trust_store,macula_dist,macula_dist_relay_client,macula_cluster_gossip) and were rewritten to preserve that exact shape rather than collapsing took. macula_record'srecord()type renamed tom_record()(macula:m_record()in the facade). OTP 29 maderecord()a reserved built-in type name; a module declaring-type record() :: ...now fails to compile with "local redefinition of built-in type". Updated every consuming-specacrossmacula_foundation,macula_frame,macula_advertise_station,macula_resolve_address,macula_host_identity, andmacula_station_link.macula_manifest.erl's moduledoc referencedmacula_manifest:get_chunk_mcid/2andmacula_manifest:decode/1, neither of which exists (the real names arechunk_mcid/3andfrom_wire/1) — caught byrebar3 ex_docwhile auditing docs for this release.- Deleted three orphaned SVGs that no guide or the README ever
referenced:
mesh-architecture.svg,pubsub_flow.svg,rpc_flow.svg. Not just unused — actively wrong for the current architecture:rpc_flow.svgclaimed "nodes never connect directly, all traffic flows through the relay mesh" (contradicted by the direct-dialcall_station/6path this guide's own two-station diagram documents), andpubsub_flow.svgnamed three modules that don't exist (macula_pubsub_handler,macula_gateway_pubsub,macula_pubsub_dht) and cited unmeasured metrics ("Cache hit: ~98%").
None of this changes runtime behavior — the catch rewrite is a pure
syntax swap, m_record() is a type-only rename, and everything else is
documentation. OTP is still pinned to 28 in .tool-versions; this
clears the specific compile blocker for a future OTP 29 move but does
not make that move itself — a separate record()-built-in-type
collision in -type record() :: #{...} (now fixed) was the other half
of that blocker, also cleared here.
[9.0.1] - 2026-08-20
Fixed
- PUBSUB_GUIDE.md was missing its
pubsub_two_stations.svgdiagram — RPC, Content, and Streaming guides all embed their matching interaction-pattern diagram at the top; PubSub's was left out when those were added. Docs-only, no code change.
[9.0.0] - 2026-08-20
LAN clustering and distribution-over-mesh split into separate concerns.
They were tangled together in one supervision tree and one source directory,
despite not depending on each other — one is same-subnet gossip/mDNS cluster
formation, the other is net_adm:ping/1 across firewalls over the mesh. Now:
macula_cluster_system/ (gossip, static, libcluster strategy) and
macula_dist_system/ (the three dist-over-mesh transports: direct QUIC,
pool-tunneled via join_mesh/1, and the dedicated freight relay via
join_dist_relay/1) are independent. See src/macula_cluster_system/README.md
and src/macula_dist_system/README.md.
Breaking
macula_dist_relayrenamed tomacula_dist_pool. The DIST_OVER_MESH_GUIDE told readers to runmacula_dist_relay:get_tunnel_metrics()directly for troubleshooting — that call now needsmacula_dist_pool:get_tunnel_metrics().macula:join_mesh/1andmacula:join_dist_relay/1(the facade, what almost everyone should be calling) are unaffected — this only breaks code that called the renamed module directly.- The
auto_clustersys.config option is removed, along with themacula_dist_systemsupervisor code that read it and conditionally startedmacula_cluster_strategyas one of its children. This was a silent behavior change before this release note: a consumer withauto_cluster => true(orapplication:set_env(macula, auto_cluster, true)) simply stopped getting automatic LAN clustering, no crash, no warning. Start clustering explicitly instead:macula_cluster:start_cluster/1(see the Clustering Guide). macula_cluster,macula_cluster_gossip,macula_cluster_static,macula_cluster_strategymoved fromsrc/macula_dist_system/tosrc/macula_cluster_system/(and their tests fromtest/macula_dist_system/totest/macula_cluster_system/). Module names are unchanged — this only affects anyone with a build script orrebar3 eunit --dir=command hard-coded to the old path.
[8.10.0] - 2026-08-19
Added
- Chunked content sharing, closing the "single block only" gap in
put_content/2/get_content/2(unchanged since v4.2.7 for blobs that fit in one 256 KiB block — same MCID, same wire calls, fully backward compatible). Larger blobs now split client-side into fixed-size chunks (macula_manifest:create/1, a byte-for-byte port of macula-station's own chunking/Merkle/MCID algorithm — same BLAKE3 NIF, same deterministic CBOR encoder, so the two sides agree without either side changing), upload each chunk, then acontent_manifestvia the station's existing (unmodified)_content.put_manifest/_content.get_manifest;get_content/2fetches every chunk, reassembles, and Merkle-verifies against the manifest before returning. macula:find_content_providers/2— resolve every host currently announcing an MCID (content_announcementrecords,macula_record:content_key/1). Combine with the existingcall_station/6to dial a specific announced host directly, guaranteeing reach regardless of the connected station's relay hop budget — the same valuecall_stationalready gives unary RPC.macula_record: fixed a real crash bug —storage_key/1had no clause forcontent_announcement(0x11, belowDOMAIN_TYPE_MIN, so it never reached the generic domain-type fallback), so everyput_recordof one raisedfunction_clause. macula-station's ownmacula_content_announcerauto-publishes acontent_announcementon every stored manifest and has since it shipped — this crash silently broke that publish path end to end. Fixed by keying onSHA-256(MCID)(content_key/1, matching macula-station's independentmacula_content_dht:dht_key/1formula) so multiple hosts announcing the same MCID land in one resolvable bag slot. Addedread_content_announcement/1.- Bounded, BOLT#4-aware retry for
_content.*CALLs (macula_bolt4:is_retryable/1— e.g.temporary_relay_failureis ratedsame_path_after_backoff, its own documented retry contract)._content.put_manifestwas observed to fail its first attempt against a freshly-started content store and succeed on retry; the station-side root cause is not yet diagnosed, but retrying is what the CALL's own error code prescribes regardless, so all four content operations do it uniformly (3 attempts, 200ms linear backoff).
Verified end-to-end against a real station (macula-station
macula_station_content_SUITE): single-block regression, multi-chunk put/get
(including out-of-order-reassembly and empty-content edge cases), and
discovery (a chunked put's announcement resolves via find_content_providers/2;
single-block content, which is not announced, resolves to {ok, []} rather
than an error).
[8.9.0] - 2026-08-19
Added
- Streaming direct-dial:
macula_client:call_stream_station/6andmacula:call_stream_station/6— open a streaming RPC by dialing a specific station directly (ensure/reuse or dial the link, await the handshake, open the stream), the streaming analogue ofcall_station/6for unary RPC. Composes with DHT resolution (find_records→read_procedure_advertisement→station_endpoint) so a stream reaches its provider in one hop, exactly like a unary caller.Optsmay setdial_timeout_ms(default 10_000).
Verified end-to-end against a real station (macula-station
macula_station_call_stream_station_SUITE): a provider advertises a
server_stream; a consumer pool seeded to nothing dials the station directly and
reads the pushed chunks to eof. A companion control case proves the pre-existing
station-routed call_stream/5 path over the same setup — cross-connection
streaming relay was already correct; this release only adds the direct-dial entry
point on top of it.
[8.8.0] - 2026-08-19
Added
- Per-subscription pubsub delivery ordering (
macula_pubsub_order). A publisher stamps every fact with a pool-monotonicseq, but the mesh sends copies down several links andmacula_clientdeduped to the first arrival — which scrambled a single publisher's stream (diagnosed: per-publisher order was lost, not just total order).subscribe/5now takes adeliveryoption:ordered(new default) — per-publisher FIFO by seq: out-of-order arrivals are buffered and released in order; a genuinely missing seq is skipped afterorder_timeout_ms(default 250ms). Buffer bounded in time (timeout) and count (order_max_buffer, default 1024) — over the cap, the head gap is skipped early.latest_only— deliver only seqs newer than the highest seen for that publisher (drop stale); no buffering, no head-of-line delay. For state snapshots.as_arrives— the previous behaviour: raw arrival order, consumer orders itself.
connect/2optionsorder_timeout_msandorder_max_buffer.status/1reportspubsub_gap_skips— the count of per-publisher gaps given up on after timeout, i.e. the genuine loss rate anorderedsubscriber could not fill. Instruments whether eventual-delivery (Plumtree lazy-repair) hardening is warranted, from live data.
Changed
- Default pubsub delivery is now
ordered(was raw arrival order). A publish/subscribe API implies per-publisher order; consumers that assumed it no longer break quietly. Consumers that want the old behaviour pass#{delivery => as_arrives}; latency-sensitive state consumers pass#{delivery => latest_only}.
[8.7.0] - 2026-08-19
Added
- Direct-dial dual-trust, Direction B (Slice 7c) — managed-realm consumer→provider
trust rooted in the realm CA via the X.509 service-cert chain, instead of the
keyless realm tag. Findings that forced the pivot: the realm tag is
SHA-256(realm_name)(no private key), and the realm holds no stable Ed25519 signing key, soverify_delegation_chain/4could never be published against a real realm. The realm CA is the authority that actually exists and is already delivered to every member at issuance.macula_record:verify_advertisement_cert_chain/3— verify a resolvedprocedure_advertisement's embedded X.509 chain (leaf → org CA → realm CA) to a trusted realm CA: advertisement signature valid, leaf binds the advertiser's Ed25519 key, chain validates (public_key:pkix_path_validation), and the leaf's organization (O) RDN equals the URI's<org>. Any failure drops the advertisement as a squat.procedure_advertisement/4gains acert_chainopt (leaf ++ org CA, PEM), carried in the record payload and surfaced byread_procedure_advertisement/1, so verification is offline and rides the record's TTL — no side lookup.
- The 8.6.0 Ed25519 delegation records (
org_directory/procedure_delegation/verify_delegation_chain) are retained but go unused on the live managed-realm path (superseded by the cert chain; not deleted).
[8.6.0] - 2026-08-19
Added
- Direct-dial dual-trust, consumer side (Slice 7c) — the realm → org → server
delegation chain:
macula_record:org_directory/3,4(realm-signedorg-name → org-key) andprocedure_delegation/2,3(org-signedserver may serve org).org_directory_key/2,procedure_delegation_key/2,read_org_directory/1,read_procedure_delegation/1.verify_delegation_chain/4— confirm an advertisement is legitimately authorized (realm signs the org key, org signs the server); any break is a squat.
Fixed
macula_record:storage_key/1for payload-field-keyed record types (procedure_advertisement,realm_member_endorsement,foundation_parameter/t3_attestation, address / hosted-address maps, plus the new 7c records) now reads the field via the robust getter, so it no longer crashes when a record is put over the SDK (put_record/2) and arrives wire-decoded with atomised keys. Previously such a put crashed the station's store handler withtemporary_relay_failure— SDKput_recordof these types was silently broken and had only ever been exercised via direct erpc puts (canonical keys). Found by the 7c e2e; also fixesprocedure_advertisementpublishing from hecate-om (Slice 2).
[8.5.0] - 2026-08-19
Added
- Provider-side capability authorization (direct-dial dual-trust, Slice 7b):
macula:advertise/5honors anauthopt:open(default, serve any identified caller) or{ucan_required, Issuer}(gate the procedure). A gated procedure verifies the CALL'sucan_tokenagainstIssuerviamacula_ucan_nif:verify/2, refusing absent/invalid with BOLT#4unauthorized.macula:call_station/7presents aucan_tokento a gated provider.- BOLT#4 code
unauthorized(0x10). The wire already encodes the code as a full byte, so the code field is unchanged. - The CALL frame gains an optional
ucan_tokenfield (rides the generic codec).
macula_client:auth_policy/0type.
All additive: advertise/4, call_station/6, call/5 are unchanged and the
default policy is open.
[8.4.1] - 2026-08-19
Fixed
macula_record:read_procedure_advertisement/1andread_station_endpoint/1now read payload fields whose keys were atomised by the frame decoder — the shape a record actually arrives in over thefind_records/2/ RPC-result path (#{serving_station => ..., procedure_uri => {text, ...}}). They previously handled only{text, _}and bare-binary keys, so a consumer resolving via the SDK gotundefinedfields and direct-dial resolution silently failed. Earlier tests usedfind_valueover erpc (which keeps canonical{text, _}keys) and missed it; the Slice 5find_recordse2e caught it. Consumers on 8.2.0-8.4.0 should upgrade.
[8.4.0] - 2026-08-19
Added
macula:connect/2now forwardsverify(webpki|none) andexpected_node_idfrom its opts to every link the pool dials — seeds ANDcall_station/6targets. A pool can therefore dial a self-signed station (verify => none, dev/loopback) or pin a station's Ed25519 identity (expected_node_id, production). Direct-dial to a resolved serving_station needs this TLS-policy control; previously links were alwayswebpki. Additive, default unchanged.
[8.3.0] - 2026-08-19
Added
macula:call_station/6— issue a CALL to ONE specific station by seed URL, dialing it directly even if it is not in the pool's seed set. The pool reuses an existing link or dials + monitors a new one, waits for the handshake within the deadline, and calls there; returns{error, not_connected}if the handshake does not complete in time. This is the direct-dial data path (resolve a serving_station + endpoint, then reach it in one hop, no mesh relay).macula_record:station_endpoint_key/1— derive a station's endpoint storage key from its pubkey (forfind_record/2before holding a record).macula_record:read_station_endpoint/1— read astation_endpointrecord'squic_port+host_advertisedas a typed map (robust to canonical vs wire-decoded payload keying).
Direct-dial discovery Slices 3 (station endpoints) + 4 (dynamic dial). See
macula-station DESIGN_DIRECT_DIAL_DISCOVERY.
[8.2.0] - 2026-08-19
Added
macula_record:read_procedure_advertisement/1— read a procedureadvertisement record's fields (procedure_uri,advertiser_node,serving_station) as a typed map, robust to both canonical (`{text, }`) and wire-decoded (bare binary) payload keying, so consumers never parse the CBOR shape themselves.macula_record:procedure_key/1— derive a procedure's DHT storage key (SHA-256(procedure_uri)) from the URI alone, forfind_records/2before holding any record.
Both are the consumer-resolution surface for direct-dial discovery (macula-station
DESIGN_DIRECT_DIAL_DISCOVERY / plan Slice 2).
[8.1.0] - 2026-08-19
Added
macula:find_records/2— multi-value DHT read returning EVERY record at a storage key (e.g. every provider that advertised one procedure_uri), wherefind_record/2returns only the first. Calls the new_dht.find_recordsrelay procedure (served by macula-station). Additive;find_record/2is unchanged. Part of direct-dial discovery (macula-stationDESIGN_DIRECT_DIAL_DISCOVERY§8.1 / plan Slice 1).
Docs
- Clarified that pubsub does not preserve per-publisher delivery order.
[8.0.2] - 2026-08-13
Identical code to 8.0.1. Republished because 8.0.1 never became resolvable.
8.0.1 published successfully by every measure hex offers: the API lists
it, has_docs is true, and the tarball at
repo.hex.pm/tarballs/macula-8.0.1.tar downloads and is a valid hex
archive. But no resolver can see it — a throwaway project asking for
{macula, "8.0.1"} gets Package not found in any repo, more than an
hour after publication, from a clean cache.
It is specific to this package rather than hex being slow: hecate_om 0.10.0, published three minutes later, resolved normally in the same
clean-room test.
Rather than wait on a stuck registry entry with no ETA, this is the same code under a version that gets a fresh one. If you are already running 8.0.1 somehow, there is no reason to move. Everything below is the 8.0.1 changelog, unchanged.
Fixed
macula_quic:getstat/2answered{ok, [{send_cnt, 0}, ...]}— well-formed, plausible and permanently zero, because the NIF binding was never written. It now answers{error, not_implemented}. Its own doc excused the zeros as "harmless (dist_util only uses these for liveness signals)", which is exactly the use a hardcoded zero destroys: frozen at zero, "nothing is moving" and "nobody implemented this" are the same reading.A sick link can no longer kill the pool.
macula_client'sstatus/1,links/1and publish path each probed every link withis_connected/1orpeer_node_id/1— both 1sgen_server:calls — from inside the pool's own process.gen_server:callexits its CALLER on both{noproc, _}and{timeout, _}, and the pool is the caller, so probing one sick link destroyed every subscription, advertisement and pending call the client held. The timeout path needs no race at all: a link merely alive and unresponsive for one second was enough, which is what a wedged station looks like from a client.link_node_id/2was worse than the other two — it calledpeer_node_id/1with no liveness guard at all, and matched only{ok, _}and{error, not_connected}, so a third reply shape was acase_clausein the pool. Same fatality, third route.Both guards answer
false/undefined, so an unreachable link is reported truthfully and conservatively and can never read as healthy.
[8.0.1] - 2026-08-13
A counter that always answers zero is worse than no counter.
Fixed
macula_quic:getstat/2 answered {ok, [{send_cnt, 0}, {recv_cnt, 0}, ...]} —
well-formed, plausible, and permanently zero, because the NIF binding was never
written. It now answers {error, not_implemented}.
Its own doc excused the zeros: "harmless (dist_util only uses these for liveness signals)". Liveness is exactly the use a hardcoded zero destroys. A counter frozen at zero makes "nothing is moving" indistinguishable from "nobody implemented the counter", so a liveness check built on it reads green forever and its author has no way to notice.
Not hypothetical. On 2026-08-13 a station received every packet sent to it, answered none for thirty hours, and every signal derived from BEAM state read healthy. Anyone reaching for a send-side counter to catch that would have found this function, and it would have lied to them.
No behaviour change for the only consumer. macula_dist:quic_getstat/1
already had an {error, _} -> {ok, 0, 0, 0} branch, so it takes the same path
and produces the same result it did before. What changes is what the next
caller is told.
Quinn has the real numbers — Connection::stats() carries udp_tx, udp_rx
and path{rtt, lost_packets, black_holes_detected} — and nif_max_datagram_size
already calls stats() and keeps only path.current_mtu. Surfacing the rest is
an extension of a working function, tracked as commit 5 of
macula-station/plans/PLAN_WIRE_LIVENESS_TRIPWIRE.md.
[8.0.0] - 2026-08-11
A service can now say why it refused.
Breaking
macula_station_link:call/5 no longer answers {error, {call_error, 16#0F, unknown_error}} when a handler refuses. It answers the handler's own reason.
0x0F is the code this SDK stamps on the wire when a handler returns
{error, Reason}, so it never meant "unknown error" in practice: it meant a
handler had said no. The ERROR clause of on_frame/2 read code and name
and dropped detail on the floor, so every refusal in the world arrived as
the same three words and no service could tell a caller anything.
%% handler
handle(_) -> {error, <<"hold_full">>}.
%% caller, before
{error, {call_error, 15, unknown_error}}
%% caller, now
{error, <<"hold_full">>}Every other code is the transport failing rather than a handler speaking, and
keeps the {call_error, Code, Name} shape. An ERROR frame with no detail,
from an older peer or from the two frames this SDK sends without one, falls
back to the tuple.
A binary reason now crosses the wire verbatim. Before, format_error_detail/1
put every reason through ~0p, so {error, <<"hold_full">>} reached the frame
as <<"<<\"hold_full\">>">>, a rendering of a binary rather than the binary, and
no caller could compare against it. Reasons that are not binaries are still
rendered: a reason that crosses a wire crosses it as bytes, so a handler that
wants its caller to match on the reason should say it in a binary. Still capped
at 256 bytes.
This also settles a contradiction at the call site rather than in the spec
table. BOLT#4 rates 0x0F log_and_caution, so macula_bolt4:is_retryable/1
answers true for it, which is right for a genuinely unknown error and wrong
for a handler that has just said no. The table is the spec's and is untouched; a
caller who gets the reason back does not have to ask.
Found by four services built against this SDK, none of which could tell a user why an order was refused.
[7.1.0] - 2026-07-26
A restarting frame recipient no longer silently black-holes a connection's
pubsub and DHT traffic. dht_recipient and pubsub_recipient now accept a
registered name as well as a pid, and the recipient is resolved on every frame
instead of being captured once in init/1.
The category-bypass guard was is_pid(Pid), and is_pid/1 is true for a
dead pid. When a station's frame dispatcher crash-restarted, every peering
connection established before the restart kept posting frames to the dead pid.
Messages to a dead pid are discarded by the VM, so those connections went
pubsub-silent (and DHT-silent) for the whole rest of their lives, with no error
logged at either end, no disconnected event, and no reconnect to trigger
recovery. The only cure was tearing the connection down.
Resolution now happens per frame:
- a registered name is re-resolved every time, so a supervisor restart is transparent — the name is re-pointed at the new pid and the next frame lands there;
- a local pid is liveness-checked;
- an unset, unregistered or dead recipient falls back to
controlling_pidin the legacy pre-4.4.3/4.4.4 frame form, which still handles every category, so the bypass degrades to the slower path instead of dropping traffic.
Consumers should pass the registered name. Passing a pid keeps working and is now liveness-checked, but a pid cannot follow a restart.
[7.0.0] - 2026-07-26
The canonical encoder now carries floats, so callers stop scaling around it.
macula_record_cbor emits IEEE 754 binary64 (RFC 8949 major type 7, additional
info 27) and decodes all three widths a conforming peer may send. to_wire/1
no longer rewrites a float as six-decimal text, and check_payload/1 no longer
rejects one.
This is a WIRE change and therefore a major: a 6.x peer decoding a float payload finds no clause for major 7 and rejects the frame, so both ends must be on 7.x before floats appear on a topic they share.
Why it took two majors to get here is the useful part. 6.0.0 rejected floats loudly, which was right at the time and wrong as a destination: it left every producer of real telemetry (hecate-victron publishes Victron voltages, currents and state of charge, almost all floats) scaling to integers to smuggle a number past our own codec. That is a workaround in consuming code for a gap in a library we own. The gap was never CBOR's — RFC 8949 has had floats since 2013 — it was that this encoder implemented a subset and nobody had needed the rest yet.
Always binary64, never the shorter half or single forms. Determinism needs one canonical encoding per value, not the shortest one; "shortest that round-trips" would make the signed bytes depend on a width-selection rule every peer must reproduce bit for bit. Nine bytes per float is the price of not having that argument. Erlang floats are always finite, so there is no NaN canonicalisation question on encode; NaN or infinity arriving from a foreign peer has no Erlang representation, matches no clause, and is rejected as a bad frame.
explain/1 no longer tells operators the mesh cannot carry floats. It can. The
6.0.0 text said otherwise and was itself a restatement of the wrong diagnosis
this release finishes correcting.
[6.0.0] - 2026-07-26
Breaking: macula:publish/4,5 can now return {error, Reason} where it
previously returned ok. The spec always allowed it; the behaviour is new.
Callers publishing raw floats, tuples, colliding map keys or oversized payloads
will see errors where they used to see success. Those publishes were not
working before, they were failing silently, so the error is the fix rather than
a regression. Major rather than minor because consumers pin ~> 5.x: shipping
this as 5.3.0 would auto-upgrade a running fleet into publish rejections nobody
chose.
macula_peering:send_frame/2 is a cast, so frames were encoded later, inside
the shared peering connection, with no try/catch around the encode. A term the
codec could not represent therefore did not fail its sender. It killed the
connection, took up to ?MAX_BATCH queued frames from unrelated producers with
it, and the sender had already been told ok. That unfalsifiable ok is why
services downstream wrap publish in defensive catch-alls: it is the only sane
response to a success value that cannot fail.
The check now runs in send_frame/2, the last synchronous point before the cast
and the one seam every producer passes through: pubsub, RPC calls and results,
streaming, advertise and content. Guarding only macula_client:publish/5 would
have left five of six verbs able to kill the link. macula_frame:check_frame/1
and check_payload/1 return {unsupported_payload_type, Type, Path} naming the
offending value and its location, and macula_frame:explain/1 renders that with
its remedy for logs. encode_or_drop/2 in the connection is the backstop for
what cannot be known without encoding: one dropped frame and a loud log instead
of a dead connection.
Rejected, each for a demonstrated reason rather than on principle:
- Floats.
to_wire/1silently rewrote them as six-decimal text, so a published52.34arrived as the string"52.34", rounded, type changed, no error anywhere.float_to_binary/2also raisesbadargat large magnitudes, so this closes a crash as well as a corruption. Scale to integers (micro-units) or send binary strings. This is not a CBOR limitation: RFC 8949 major type 7 is floats andmacula_cbor_nifhandles them natively. It is a limitation of the canonical envelope encoder, which payloads should not be traversing at all, and fixing that properly is a wire-format change. - Colliding wire keys. An atom, a binary and a
{text, Binary}of the same name are one key on the wire, so#{foo => 1, <<"foo">> => 2}shipped as two pairs and arrived as one, the loser chosen by sort order. - Oversized payloads, via a lower-bound size estimate, sound for rejection.
- Tuples, bitstrings, out-of-range integers, improper lists, pids, refs, funs and ports, all of which crashed the encoder.
Also: macula_record_cbor:is_encodable_int/1 is exported so the 64-bit bound
lives only where the constant does; RPC results the wire refuses now fault the
call with a BOLT#4 call_error instead of leaving the remote caller to burn its
deadline; and a refused publish no longer consumes a sequence number, which
would have faked a gap in the (publisher, seq) sequence station dedup keys on.
Known limits, stated rather than papered over. check_frame/1 excludes
record / records, which go through macula_record:encode/1, so
record-bearing frames (STORE, REPLICATE, VALUE) are protected by the backstop
but do not get a structured reason. The receive side still wedges on a malformed
frame: drain_step/2 returns the buffer unadvanced, so the prefix is re-parsed
forever. {text, B} is not validated as UTF-8, which is a deviation a strict
non-Erlang peer would reject.
[5.2.2] - 2026-07-23
Fixed: pool-owned publish sequence. The outbound PUBLISH seq was a per-link
counter that reset to 0 whenever a station link respawned, while the publisher
identity (the pool's Ed25519 pubkey) stayed constant. The station-side
(publisher, seq) dedup keys on that pair, so a link flap re-issued seqs that
had already been seen — a latent duplicate/false-drop once that dedup becomes
authoritative (Phase 3 of macula-station PLAN_PUBSUB_E2E_SIGNED_EVENTS).
macula_client now owns a monotonic publish_seq, seeded from wall-clock
microseconds at init so a pool restart cannot re-issue seqs that collide with
the pre-restart tail still inside a station's dedup window, and stamps the same
seq on every replicated link via the new macula_station_link:publish/5.
publish/4 retains its per-link counter for standalone (pool-less) link use.
No wire-format or public-API break. This lands the "add a per-pool seq counter
to macula_client" prerequisite the pubsub-e2e-signed-events plan calls for.
[5.2.1] - 2026-07-15
Liveness/backoff tuning now falls back to the macula application env. 5.2.0
exposed the knobs as start_link/1 opts, but a consumer creates links from
several places (the realm holds ~64 across its Mesh pool, DHT/directory
subscribers, and the topology pool), and only the explicitly-wired call site
picked up the widened values -- the rest kept the tight 30s/2 default and kept
flapping on overloaded stations. Now macula_station_link reads the macula
application env as the default when an opt is unset, so one line --
config :macula, liveness_max_misses: N, liveness_interval_ms: Ms -- widens
EVERY link regardless of which subsystem spawned it, and stays tunable from the
deployment (env-driven sys.config) without a code change. Explicit start_link
opts still win; the module ?DEFINEs stay the ground default.
[5.2.0] - 2026-07-15
Tunable station-link liveness. The app-level liveness probe in
macula_station_link was hardcoded at a 30 s interval with a 2-miss
teardown, so a client holding many links to variously-loaded stations
would recycle a link whenever a busy-but-alive station failed to answer
two consecutive _macula.ping CALLs in time — observed as ~1 link
flap/45 s on the realm's station pool when relay boxes ran hot (load ~6 on
2 vCPU), each flap triggering a full re-subscribe storm.
Added
macula_station_linkstart_link/1optsliveness_interval_msandliveness_max_misses, each defaulting to the previous constants (30 000 / 2). A consumer with a pool of links to busy stations (the realm) can widen the tolerance so a slow-but-alive link is not recycled; the daemon and wardens keep the tight default for fast zombie detection.macula_station_linkstart_link/1optconnect_retry_backoff_ms(default 1_000) — the wait before re-dialling after a failed connect. Raise it on a pool that cycles links so torn links don't reconnect in lockstep and hammer the station with re-subscribe storms. Also documented the QUIC transport knobs (idle_timeout_ms,keep_alive_interval_ms, stream counts) already forwardable via theseedmap. Backward compatible — unset opts preserve the prior behaviour exactly.
[5.1.0] - 2026-07-09
Connect-reliability release. Fixes the root cause of the
macula.io/clankercab outage: on a long-lived client that holds many
station-links (the realm ran ~64 across several subsystems), every
macula_quic:connect would eventually hang forever, leaving the client
with zero live links. Two independent NIF defects plus a client-side
self-heal gap.
Fixed — QUIC connect NIF (native/macula_quic)
- The dial timeout now always fires.
nif_connectonly wrapped the handshake intokio::time::timeout, leavinglookup_hostunbounded, and under runtime pressure even the handshake timeout would not fire — so a stalled/black-holed dial parked forever. The whole operation (DNS + endpoint + handshake) is now under a single deadline; a dial to an unreachable peer returns{error, connection_timeout}attimeout_msinstead of hanging. Verified: reachable station connects in ~75 ms; black-hole address returnsconnection_timeoutat the deadline. - One shared client
Endpointper address family, not one per dial. Eachconnectused to build a freshquinn::Endpoint— a new UDP socket + a new endpoint-driver task on the shared 4-worker tokio runtime. On a client that reconnects continuously these accumulated hundreds of driver tasks/sockets and starved the runtime's reactor/timer (which is why the timeout stopped firing). Connections now multiplex over a single shared endpoint per family. - Blocking network NIFs moved to dirty-IO schedulers.
nif_connect,nif_open_streamandnif_sendwere scheduledDirtyCpu— there is one dirty-CPU scheduler per core (two on the realm box), so a couple of blocking dials pinned them all and starved every other QUIC operation. These are IO-bound and now run on the dirty-IO pool (far larger), matching their nature.
Fixed — connect self-heal
macula_station_linkconnect watchdog. A link whose peering worker connects at the transport layer but never delivers{macula_peering, connected, ...}— e.g. a QUIC dial NIF that hangs past its owntimeout_ms, or a stalled CONNECT/HELLO exchange — used to sitalive-but-not-connectedindefinitely:peer_pidset,peer_node_idundefined, subscriptions queued, nodisconnected, no owner:DOWN, no retry. The app-liveness probe did not cover this (it only arms afterconnected), so there was no bound on the un-connected phase and no self-heal after mesh churn. A watchdog is now armed the moment the peering worker is spawned and cancelled onconnected; if it fires while still un-connected it kills the wedged worker and stops the link so the owner respawns a fresh dial — bounded, automatic self-heal. Deadline isconnect_timeout_ms + 10s, overridable via theconnect_watchdog_msstart opt. Live-diagnosed on themacula.io/clankercaboutage (2026-07-09), where every realm subscriber link was wedged this way.
[5.0.0] - 2026-06-10
Security release. Four findings from the 2026-06-10 transport-trust audit, the first of which changes a default and makes this a major version.
Security — BREAKING
- QUIC dials verify the server certificate by default.
macula_quic:connect/4now defaultsverifytowebpki(webpki roots + hostname check) instead ofnone. Previously every peering dial silently accepted any server certificate, allowing a network MITM to impersonate any peer. The production relay fleet serves Let's Encrypt*.macula.iocerts and is unaffected. Migration: self-signed setups (local dev, lab clusters, e2e harnesses) must either pin the peer identity (preferred — seeexpected_node_idbelow) or opt out explicitly with{verify, none}/#{verify => none}in the seed/target. Every unverified dial now logs a warning.
Security
- Peer identity binding on the CONNECT/HELLO handshake.
macula_peering_conntargets (andmacula_station_linkmap seeds) acceptexpected_node_id => Pubkey. When set, (a) the QUIC dial pins the server cert's Ed25519 SPKI to that key, and (b) the HELLO is rejected with{peer_identity_mismatch, Expected, Got}unless the peer's verifiednode_idequals it. Previously the handshake only proved the peer holds the key for whatever identity it claimed — no binding to the dialed target existed. - Topology bootstrap fetch now verifies TLS.
macula_relay_discoveryfetched/topologywith{verify, verify_none}, letting a MITM steer a bootstrapping node onto attacker relays. Nowverify_peeragainst the OS trust store with wildcard-aware hostname checking. - DHT-sourced node info decoded with
binary_to_term(_, [safe]).macula_dist_discoverydecoded DHT values (attacker-influenceable) unsafely — a crafted record could exhaust the atom table or allocate unbounded resources. Records are now shipped atom-free (name/protocolas binaries), decoded[safe], and shape-validated; the node name is rebuilt from the caller's argument, never atomized from DHT bytes. Mixed-fleet note: 5.0.0 nodes reject dist-discovery records written by older nodes (atomnamefails the safe decode) until those nodes re-register on refresh with the new format.
[4.8.0] - 2026-05-31
Added
macula:links/1(andmacula_client:links/1) — per-link pool snapshot. Returns onelink_info()map per spawned link, carryingseed,host,pid,connected, and the peer station'snode_id(pubkey;undefineduntil CONNECT/HELLO completes).status/1only aggregates counts — this exposes the individual links so a caller can resolve a specific station (by pubkey or hostname) to its link for targeted, per-station operations.
Test coverage
macula_client_tests: empty pool →[]; one entry per spawned link with host parsed from URL and map seeds; unconnected links reportconnected=false/node_id=undefined.
[4.7.1] - 2026-05-17
Fixed
macula_record_cbor: encode + decode CBOR major type 1 (negative integers). Bothencode/1anddecode_value/3lacked clauses for signed integers; any payload carrying a negative numeric value crashed the encoder withfunction_clauseand tore down the peering connection with it. The new clauses mirror the positive-integer path across the full int64 range.macula_frame:to_wire/1+wire_key/1: accept integers of any sign.to_wireonly passed non-negative integers through; negatives fell into the catch-all and reached the encoder unchanged (then crashed there).wire_keyhad no clause for integer keys at all. Both now accept integers, enabling payloads whose nested maps are indexed by integer (e.g. per-wall sub-maps in mpong game state).
Test coverage
macula_record_cbor_tests: negative-int round-trip across the mirror of the existing uint range, plus an int-key map round-trip.
Backport
- Same fix shipped as
4.4.10for the active 4.4.x line that hecate-daemon and friends still pin against.
[4.7.0] - 2026-05-16
Performance
Per-link send-frame batching in
macula_peering_conn. Theconnectedstate'scast {send_frame, _}handler now drains all queuedsend_framecasts (cap 64 per pass) and emits them in a singlemacula_quic:send/2NIF call. Cuts per-NIF overhead- gen_statem reduction-counter cost when many EVENT/PUBLISH frames burst together (pubsub flood, DHT batch put). The Quinn stream still handles MTU-level packetisation; this is purely an Erlang-side amortization. Single-frame fast path preserved.
SDK subscriber-side bulk fan-out in
macula_station_link.handle_info({macula_peering, frame, ...})now drains consecutive frame messages from the gen_server mailbox (cap 64 per pass) and folds them throughon_frame/2in arrival order. Removes per-frame context-switch + reduction-reset overhead on bursty inbound traffic.Quinn flow-control window defaults bumped (in
native/macula_quic/src/config.rs):stream_receive_window: default 1.25 MB → 16 MBreceive_window: default 1.25 MB × streams → 64 MBsend_window: default 8 MB → 64 MB
Macula peering uses one long-lived bidi stream per connection over which we multiplex pubsub EVENTs, CALL/REPLY, DHT records, blob streams. With many small frames in flight and the receiver doing per-frame Ed25519 verify (~200µs), the default 1.25 MB window exhausts long before the receiver acks consumed bytes — surfaces as receiver-bound throughput in pubsub flood torture. 16 MB stream window absorbs ~100k 150-byte EVENT frames before backpressure. Applies symmetrically to client + server transport configs.
[4.6.0] - 2026-05-15
Changed
pubsub_emit_publisher_sigdefault flipped fromfalsetotrue. Publishers now attach an end-to-end publisher signature to every outbound PUBLISH/EVENT by default. Wire-format change is fully BC (the field has been parseable since 4.4.0 and excluded fromcanonical_unsigned/1so the per-hop relay signature stays valid). On the receiver side, stations on >= 4.4.0 already preferverify_publisher/1for EVENTs that carry the signature (so they pass at any relay hop instead of only one), and themacula_station_event_dedupcache drops{publisher, seq}repeats.Effect: multi-hop pubsub now works end-to-end. Previously cross-station PubSub was bounded to 1 hop from origin because the relay re-signed EVENTs and the verify-mismatch at hop 2+ doubled as accidental loop kill. Publisher-end-to-end signatures + dedup removes that bound; loops are killed structurally by
(publisher, seq)deduplication, not by signature failure.Operators on a fleet that still has pre-4.4.0 stations can opt back out:
application:set_env(macula, pubsub_emit_publisher_sig, false)per node. Our deployed fleet is on macula-station with SDK 4.5.0 so this knob applies cleanly.Four prior incremental attempts (per-hop re-sign, 1-hop cap, two dedup variants) at this same problem all regressed cross-station traffic and were reverted. The wire foundation (
publisher_sigfield, dedup cache observe-only) was landed in 4.4.0; this release just flips the default so the foundation is exercised.
[4.5.0] - 2026-05-14
Added
App-level liveness probe in
macula_station_link. Periodic_macula.pingCALL every 30s (?LIVENESS_INTERVAL_MS) with reply tracking vialiveness_outstanding. After?LIVENESS_MAX_MISSES(=2) consecutive misses, the link issuesmacula_peering:close(PeerPid, app_liveness_lost)which surfaces through the normaldisconnectednotify path → station_link stops → pool respawns with a fresh QUIC handshake.Closes the zombie-connection window that previously lasted up to the Quinn
max_idle_timeout(5 minutes) — empirically observed going much longer in production because the server's Quinn keeps ACKing keep-alive PINGs at the transport layer even after the application-level peer has been wiped (e.g. station container restart). Reply matching consumes the call_id BEFORE the user- pending CALL machinery, so probes do not show up in caller-visible RESULT / ERROR streams.macula_peering:peer_capabilities/1getter and?CAP_STATIONcapability bit (1 bsl 0). Peers can now introspect the counterpart's capability bitmask post-handshake, letting relay stations tell direct daemon ADVERTISEs from station-to-station gossip relays at frame-dispatch time. Daemons leave the bit unset; relay stations OR it in. Existing peers default to 0 (treated as daemons) — full BC.Synchronous call into a non-
connectedstate now repliesnot_connectedimmediately rather than blocking until the caller's own gen_statem timeout. Surfaced via the existingdrop_unexpected/4clause; one-line behavioural improvement.
Internal
#data{peer_capabilities :: undefined | non_neg_integer()}field onmacula_peering_conn. Populated inabsorb_peer_info/2from the CONNECT / HELLO frame. No wire change (the field was already on the frame schema; we just record it).Fixed
macula_peering_handshake_tests'absorb_peer_info_populates_fieldstest — was assertingelement(14, Data) =:= [Realm](a hardcoded offset) but the #data record grew several fields since the test was written, so it had been silently picking up thequic_streamreference. Now uses a structural search across all elements; resilient to future record additions.
[4.4.9] - 2026-05-14
Added
signerfield on STREAM_DATA / STREAM_END / STREAM_ERROR frames. The emitter (amacula_station_linkinstance) now stamps its Ed25519 pubkey into the frame before signing. Stations can then verify the signature end-to-end against the claimed signer instead of the inbound conn's NodeId — necessary for multi-hop relays where the inbound conn is a peer station, not the originating daemon.Frame shape:
#{stream_id, seq, encoding, body, signer => <<_:256>>}(analogous tostream_reply'sresponded_byand CALL'scaller).Wire-compat:
signeris additive; old stations ignore it and keep verifying against inbound NodeId (single-hop only). Old SDKs don't emit it; new stations fall back to NodeId verify for frames missing the field. The two ends meet correctly after a coordinated rollout.Motivation: cross-station streaming RPC was failing because STREAM_DATA chunks emitted by daemon-A and relayed via station-A → station-B were verified at station-B against station-A's NodeId. The signature was made by daemon-A, so verify failed and station-B silently dropped every chunk. The chunks then never reached the caller daemon-C connected to station-B, and the caller's recv hit the 5s timeout. With
signer, station-B verifies against daemon-A's pubkey and the chunks flow correctly.Stations on macula-station >= 4.4.9-aware will use claimed_signer for stream_data/end/error and claimed_replier for stream_reply.
[4.4.8] - 2026-05-14
Fixed
Pool gen_server serialisation under concurrent CALL/STREAM/PUBLISH.
macula_client'shandle_call/3for{rpc_call, ...},{rpc_call_stream, ...}and{publish, ...}used to synchronously fan out to per-linkgen_server:call/3and block the pool until each link replied. N concurrent callers all queued at the pool, capping concurrent throughput at 1. The harness'smany_concurrent_calls,many_concurrent_streamsandmulti_publisher_pubsubcases were the visible failures — N=5 callers serialised through one gen_server with each link call timeoutable at 5s.Each handler now spawns a one-shot worker that does the fanout and replies via
gen_server:reply/2. The pool returns to its mailbox immediately. N concurrent callers run in parallel.Behaviour from the caller's POV is unchanged —
macula:call/4is still a sync gen_server:call against the pool; only the path inside the pool changed.Advertise/unadvertise/subscribe/unsubscribe stay synchronous since they mutate pool state (
procs/stream_procs/subsmap) before reaching out to links. The harness only fires these once per case so they're not on the contention path.
[4.4.7] - 2026-05-14
Added
timing_enabledoption onmacula_peering_conn. When set, every inbound-frame notification carries an extra trailingRecvAtUs :: integer()element —erlang:monotonic_time(microsecond)captured the instant the frame finished decoding. Recipients can subtract their own monotonic clock at dispatch time to compute mailbox wait at the receiving gen_server.Wire shapes when enabled:
{macula_peering, frame, ConnPid, Frame, RecvAtUs} {macula_peering, dht_frame, ConnPid, NodeId, Frame, RecvAtUs} {macula_peering, pubsub_frame, ConnPid, NodeId, Frame, RecvAtUs}Default is
falsefor backward compatibility — recipients that have not been updated keep matching the legacy 4-/5-tuple shapes.macula_station_linkhas been extended to match both shapes so daemons receiving from a station that has opted in continue to work.Motivation: macula-station's
macula_station_peer_observerruns a persistent mailbox depth of 200-400 frames under DHT load. Without aRecvAtUsstamp the mailbox wait is invisible to a downstream observer —process_info(self(), message_queue_len)at dispatch entry is a coarse proxy but doesn't tell you how old the frame at the head of the queue actually is. Withtiming_enabledthe receiver gets per-frame wait + dispatch + forward latency, which is what we need to know whether peer_observer is still the bottleneck after 4.4.3/4.4.4's DHT and pubsub ETS bypasses.
[4.4.6] - 2026-05-14
Fixed
Same-pool streaming RPC, take two. The 4.4.5 split of
streamsintoclient_streams/server_streamswas necessary but not sufficient. The remaining failure: when the server-side handler calledmacula:close_stream/1, the outbound STREAM_END cast ran throughon_outbound_stream_frame/3and calleddrop_stream/2, which cleared the Sid from BOTH maps. The relay's bounced-back STREAM_DATA chunks then arrived at the same link, hitfind_stream/2with no entry, and were silently dropped. The caller'srecvwaiter timed out at 5s.Fix:
maybe_drop_outbound/2skips the drop when the same Sid lives in both maps (the same-pool case). Inbound terminal frames (deliver_stream_end/deliver_stream_error/deliver_stream_reply) still calldrop_stream/2and tear down both entries when the bounced terminal arrives — so the lifecycle closes cleanly without losing the chunks in between.Different-pool streaming is unaffected: each link holds only one side of the stream, so the same-Sid-in-both-maps test is always false and the outbound drop still runs as before.
[4.4.5] - 2026-05-14
Fixed
Same-pool streaming RPC. Splits
macula_station_link's sharedstreamsmap intoclient_streamsandserver_streamsso anadvertise_stream+call_streamon the same pool no longer loses the caller's recv waiter. Previously, the relay bounced the STREAM_OPEN back over the same conn andspawn_inbound_stream/6overwrote the client entry under the same Sid, routing subsequent STREAM_DATA chunks to the server-side producer pid instead of the client's recv waiter. Caller hit the recv timeout (8s) with no chunks. After the split, inbound dispatch triesclient_streamsfirst (server_stream mode, the common case), falling through toserver_streamsfor client_stream / bidi server-receive.Affects:
macula_e2e_probe:streaming_rpc/4andmany_concurrent_streamsin the diagnostics harness — both were timing out at exactly 8003ms before this fix.Cross-station streaming (different pool, different bootstrap) is a separate bug at the station level (multi-hop STREAM_DATA verify fails against the inbound peer-link's NodeId rather than the end-to-end signer); tracked separately and requires a frame-schema bump on STREAM_DATA / END / ERROR / REPLY.
[4.4.4] - 2026-05-13
Added
pubsub_recipientoption onmacula_peering_conn. Mirror of the 4.4.3dht_recipientbypass — when set, pubsub-class frames (subscribe,unsubscribe,publish,event) go directly to that pid as{macula_peering, pubsub_frame, ConnPid, PeerNodeId, Frame}instead of throughcontrolling_pid. All other frame types follow the existing path.Backward-compatible:
pubsub_recipientdefaults to undefined; in that case every frame keeps flowing throughcontrolling_pidexactly as before.Motivation: after the DHT bypass shipped in 4.4.3, the dominant load on a station's
macula_station_peer_observermailbox shifted to pubsubeventframes (suite measurement: 90% of post-bypass mailbox sample ={frame, event}, vs 85%{frame, store/store_ack}before). Each EVENT carries an Ed25519 publisher signature that needs verification before fan-out; under multi-publisher bursts the verify cost dominates and the same observer mailbox that serializes handler-dispatch and ADVERTISE / SUBSCRIBE propagation backs up again. Stations on macula >= 4.4.4 wire this opt to a dedicatedmacula_station_pubsub_dispatchergen_server.See macula-station's
macula_station_pubsub_dispatcherfor the receiver-side implementation, plus thepubsub_recipientplumbing inmacula_station_listener:peering_opts/1andmacula_station:compose_dial/2.
Internal
- The frame router in
macula_peering_conn(route_frame/2+route_by_category/4) now classifies each parsed frame once and dispatches by category, instead of a per-recipient inline check. The category mapping (dht/pubsub/other) mirrorsmacula_station_peer_observer:classify/1— any new frame type added on the station side must be added on the SDK side too.
[4.4.3] - 2026-05-13
Added
dht_recipientoption onmacula_peering_conn. When set to a pid, DHT-class frames (ping,pong,find_node,nodes,find_value,value,store,store_ack,replicate,replicate_ack) bypasscontrolling_pidand go straight to that pid as{macula_peering, dht_frame, ConnPid, PeerNodeId, Frame}. All other frame types continue to flow throughcontrolling_pidin the existing{macula_peering, frame, ConnPid, Frame}form.Backward-compatible: when
dht_recipientis unset (the default), every frame goes throughcontrolling_pidexactly as before. No callers in the macula SDK itself set this; daemons and tests are unaffected.Motivation: in the deployed station fleet, ~85% of inbound frames per peering connection are DHT
store/store_ackchatter from record replication. Funnelling them through the station's singlemacula_station_peer_observergen_server meant every other inbound frame type (CALL, REPLY, ADVERTISE, SUBSCRIBE, PUBLISH, EVENT) sat behind a 200-400-deep mailbox of DHT pass-through work, adding 700-1000 ms of dispatch latency per hop on the live Leuven fleet. Stations can now route DHT frames directly to theirmacula_dhtserver instead of stacking them in the observer's queue.See macula-station's
macula_station_listener:peering_opts/1andmacula_station:compose_dial/2for the station-side wire-up.
[4.4.2] - 2026-05-13
Added
Subscriber-side
publisher_sigverification. Step 4 of the pubsub Phase 2 redesign (seemacula-station/plans/PLAN_PUBSUB_E2E_SIGNED_EVENTS.md). Whenmacula_station_linkdelivers an inbound EVENT that carries apublisher_sig, it now verifies it (macula_frame:verify_publisher/1) against the EVENT's ownpublisherfield before fanning it to subscribers. An EVENT with nopublisher_sigis delivered as before (legacy / feature off everywhere). An EVENT whosepublisher_sigis present but invalid is always logged atwarning; it is delivered anyway by default (a relay bug should surface, not silently lose events, during the Phase 2 rollout) and dropped only when themaculaapplication envpubsub_strict_publisher_sigistrue.No on-wire change: still nothing emits
publisher_sigunlesspubsub_emit_publisher_sigis enabled (4.4.1), so by default this is a no-op.
[4.4.1] - 2026-05-13
Added
Opt-in
publisher_sigemission on outbound PUBLISH frames. Step 1b of the pubsub Phase 2 redesign (seemacula-station/plans/PLAN_PUBSUB_E2E_SIGNED_EVENTS.md).macula_station_linknow attaches apublisher_sig(macula_frame:sign_publisher/2) to each PUBLISH frame it sends — only when themaculaapplication envpubsub_emit_publisher_sigistrue. Defaultfalse— i.e., unchanged on-wire behaviour out of the box.**Do not enable until every relay (macula-station) is on macula
= 4.4.0.** A pre-4.4.0 relay's
canonical_unsigned/1strips onlysignature(notpublisher_sig) when checking a frame's per-hop signature, so it would reject a PUBLISH that carriespublisher_sig. Rollout: macula >= 4.4.0 on the whole fleet → confirm → set{macula, pubsub_emit_publisher_sig, true}on the daemons → then macula-station's relay path carriespublisher_sigonto the EVENT and verifies relayed EVENTs against the publisher (a later step).Read per publish (a fast env lookup), so the flag can be flipped at runtime without a daemon restart.
[4.4.0] - 2026-05-12
Added
Publisher-end-to-end pubsub signature (
publisher_sig). Step 1 of the pubsub Phase 2 redesign (seemacula-station/plans/PLAN_PUBSUB_E2E_SIGNED_EVENTS.md). PUBLISH and EVENT frames may now carry an optionalpublisher_sigfield — the publisher's Ed25519 signature over the canonical, frame-type- independent tuple(topic, realm, publisher, seq, payload), signed under the new"macula-v2-event-pub\0"domain. Because the signed content excludes header fields,delivered_via, andttl_ms, the signature a publisher puts on its PUBLISH is still valid on the EVENT a relay station derives from it — so a relay can stop re-signing and consumers can verify authenticity against the publisher regardless of which relay delivered the event. New API:macula_frame:sign_publisher/2,macula_frame:verify_publisher/1.macula_frame:publish/1andevent/1accept an optionalpublisher_sigin their spec.publisher_sigis excluded from the bytes covered by a frame's own per-hopsignature(canonical_unsigned/1now strips both), so adding it never invalidates the per-hop signature.Wire-safety / rollout note. This release does not emit
publisher_siganywhere — the SDK's publish path is unchanged, so a 4.4.0 node produces byte-identical frames to 4.3.1. The field is plumbed and ready; a later step has the publish path populate it. That later step must not ship until every relay (macula-station) is on a 4.4.0-compatible build — a pre-4.4.0 relay strips onlysignature(notpublisher_sig) when checking a frame's per-hop signature, so a frame carryingpublisher_sigwould fail its verification. Order: SDK 4.4.0 everywhere → stations updated → then flip onpublisher_sigemission.
[4.3.1] - 2026-05-12
Fixed
macula_clientpublish now selects only connected links. The{publish, ...}pool handler took the firstreplicationspawned link pids and published to them — including links still mid-handshake. A frame sent to a not-yet-connected link is dropped (unlike ADVERTISE, which the link replays on connect), somacula_pubsub:publish/4,5could return{error, not_connected}while other links in the pool were healthy. Now filtered throughmacula_station_link:is_connected/1(newconnected_link_pids/1helper), matching how RPC (call_first_success) and streams (stream_first_healthy) already pick links. With no connected link the result is the existing transient{error, {transient, no_healthy_station}}(retryable) rather than{error, not_connected}.
[4.3.0] - 2026-05-11
Added
macula_z32codec module. z-base-32 (Phil Zimmermann's "Human-Oriented Base-32 Encoding"; alphabetybndrfg8ejkmcpqxot1uwisza345h769). Used for encoding 32-byte Ed25519 pubkeys as DNS-label-friendly strings (32 bytes → 52 chars, fits the 63-char per-label cap). Same encoding used by PKARR and Pubky for the same reason. API:encode/1,decode/1,is_valid_label/1. Pure Erlang, no NIF; MSB-first bit packing. 18 eunit cases covering empty/round-trip/length contracts, hand-computed test vectors (zero32, ones32, small-mixed, single-byte), property-based round-trip over 200 random samples per size class, alphabet-rejection, andis_valid_label/1guard cases.stationMRI type.mri:station:<52-char-z32-pubkey>. Self-rooted (the realm field carries the pubkey directly; no reverse-domain notation; path must be empty). Validation routes through the new z32 codec rather than the reverse-domain regex. Required byhecate-daemon'sserve_dns_over_meshslice for synthesising station qnames (e.g.,<z32(pubkey)>._st.macula.io.). Also added tomacula_mri_registrybuiltin types list. 9 eunit cases covering parse/format/round-trip/new-via-general-constructor + four rejection cases (path present, short pubkey, invalid z32, uppercase pubkey).
Notes
4.3.0 is purely additive over 4.2.x. No existing API changes; downgrade compiles cleanly. Downstream consumers (
hecate-daemon,macula-station,macula-realm) can bump~> 4.2.9to~> 4.3.0whenever convenient; no coordinated upgrade required.dane_pin(record type 0x15) andcoverage_proof(0x16) remain on the 4.4.0 candidate list. Neither is on the critical path forserve_dns_over_meshPhase 1 (which falls back to SERVFAIL+EDE("coverage_unknown") for NXDOMAIN proofs and NOTIMP+EDE("tlsa_unsupported") for TLSA queries) orserve_https_over_mesh(which verifies station pubkeys via the leaf cert SAN OtherName extension, not via TLSA).
[4.2.9] - 2026-05-10
Fixed
subscribe_records/3now decodes the wire payload before invoking the user callback. Previously the callback received the rawmacula_record:encode/1binary; the documented contract said it would receive the decoded record map. The probe pair added inmacula-internal/macula-e2e@8831d1esurfaced both this and the substrate-side topic mismatch (substrate publishes on_dht.records.<type>.storedas ofmacula-internal/macula-stationrecipient commit). Together the two changes makesubscribe_records/3work end-to-end as documented.The wrapper accepts either binary (encoded) or map (already decoded) payloads — the latter for callers who feed records through alternate channels.
[4.2.8] - 2026-05-09
Fixed
macula_blake3_nif:hash/1now force-loadsmacula_crypto_nifbefore checking the NIF-loaded flag. Pre-fix the function readis_nif_loaded()directly, which returnsfalseuntil themacula_crypto_nifmodule is referenced for the first time (its-on_loadcallback writes the persistentterm flag). If a caller's first-ever NIF use went throughmacula_blake3_nif:hash/1rather than something that touchedmacula_crypto_niffirst, the Erlang fallback fired — and that fallback is NOT plain `crypto:hash(sha256, )`: inputs over 1024 bytes are tree-hashed (1024-byte chunks SHA-256'd individually, chunk hashes pair- hashed), producing output that matches neither real BLAKE3 nor plain SHA-256.Surfaced by
macula:put_content/2in v4.2.7: blobs > 1024 bytes computed an SDK-side MCID that no relay could verify, so every_content.put_blockreturnedhash_mismatch. The four other hash entry points (hash_streaming/1,verify/2,hash_hex/1) had the same bug; all are fixed in lock-step.is_nif_loaded/0is retained for diagnostic use but its docstring now warns that the answer reflects whatever has been observed so far. New private helperensure_crypto_nif_loaded/0is the authoritative path.
[4.2.7] - 2026-05-09
Added
macula:put_content/2andmacula:get_content/2— content- addressed blob storage and retrieval over the relay.put_contentcomputes the BLAKE3 hash of the bytes, packages them into an MCID (<<1, 16#55, Hash:32/binary>>), and ships the blob to the relay via a single_content.put_blockRPC; the relay verifies the payload's hash matches the MCID before accepting.get_contentfetches the blob back via_content.get_block, returning{error, not_found}if no provider in the pool's reach holds a copy.v4.2.7 minimum-viable surface — single-block per blob, no client-side chunking, single-station semantics. Suitable for blobs in the kilobyte-to-low-megabyte range. Blobs larger than the relay's per-call payload budget will surface as a CALL-deadline timeout; chunked manifests + multi-provider parallel fetch land in a follow-up release. Cross-station discovery (writer + reader on different relays) requires the relay-side iterative-fetch fallback that already lands for
_dht.find_record(commit c11226f in macula-station) — wire-symmetric for content once exposed.mcid()type added (<<_:272>>= 34 bytes). Hashing uses the existingmacula_blake3_nifthat was previously only consumed by the record-signing path.
[4.2.6] - 2026-05-09
Fixed
macula_peering_conn:on_connect_verified/4no longer crashes whensend_helloreturns{error, _}. Previously assertedok = send_hello(Stream, NewData)on the server-side handshake completion path; under teardown bursts (multiple peers closing pools simultaneously, e.g. e2eend_per_suiteacross a fleet),macula_quic:sendcould legitimately return{error, "connection lost"}between the CONNECT-verify and the HELLO write — the peer's QUIC stream was already gone. The badmatch crashed the peering_conn gen_statem worker; under load enough concurrent crashes tripped the parent supervisor's restart-intensity and forced a whole-station restart.Now mirrors the existing graceful handling on the client-side
send_connectpath (lines 232-240): emit a structured{send_hello_failed, _}disconnect notify and stop normally, so the supervisor cleans up without counting it as a crash.Surfaced 2026-05-09 by macula-station eager-replication-on-put load, which amplified the race; reverted at the station layer and shipped here so eager replication can be re-enabled cleanly after publishing.
[4.2.5] - 2026-05-09
Fixed
Pool fan-out (
macula_client) no longer filters byis_connected/1. The four fan-out helpers (fanout_advertise/4,fanout_unadvertise/3,fanout_advertise_stream/5,fanout_unadvertise_stream/3) used to skip pre-handshake links, which left the link's localproceduresmap out of sync with the pool's intent. A subsequentunadvertiseon the same key would skip the link too — its local map kept the proc — and the link silently re-ADVERTISED on the next handshake, causing the relay station to register a stale procedure that nothing in the SDK would ever withdraw. The leak only resolved on daemon disconnect, when the station'spurge_connfired.Each fan-out now dispatches to every LIVE link (filtered by
is_process_alive/1only). The link gen_server'sadvertise/unadvertisehandlers update the local map regardless of connection state; the wire frame is best-effort insidemaybe_send_advertise/maybe_send_unadvertise(no-op when pre-handshake). On the next handshake,drain_pending_advertises/1replays the now-correct map.Surfaced by 2026-05-09 mesh torture:
e2e.cross.echo.{N}entries persisted on stations across rounds withadvertiser=PoolDaemonPubkeyeven thoughunadvertise/3had returnedok. Tombstones inmacula_remote_advertise_registry(macula-stationc7d8fe8) solve the gossip-vs-unadvertise race; this commit closes the pre-handshake-skip path that re-creates a fresh stale entry on every reconnect.Per-link errors are now wrapped in try/catch (
safe_link_advertise/4,safe_link_unadvertise/3, stream variants) so a single dead pid cannot crash the whole pool gen_server.
[4.2.4] - 2026-05-08
Fixed
macula_peering_connserver-side handshaking now takes ownership of inbound streams. When a server accepts a new conn and the client opens a stream, Quinn creates theStreamResourcewith its owner field set to whatever owns the conn AT THAT MOMENT. On the accept path that's still the listener — the conn-ownership transfer hasn't fired yet. Callingsetopt(Stream, active, true)on its own does NOT change ownership; it only flips the active-delivery flag. Future{quic, Bin, Stream, _Flags}events therefore went to the listener's mailbox and got silently dropped by its wildcardhandle_info/2. The worker sat inhandshakingwithbuf_size = 0until its 30s timeout, even though 4.2.3'sawaiting_startpostpone clause + macula-station's stray-event forwarder both delivered thenew_streamnotification on time.Fix: call
macula_quic:controlling_process(Stream, self())in the server-sidehandshakingnewstream handler beforesetopt. The worker is now the explicit stream owner, so subsequent `{quic, Bin, Stream, }` events route to it directly.Pairs with macula-station's listener forwarding fix (commit
85dff3eon macula-internal/macula-station): together they close the cross-station handshake race that was leaving every station with tens of stuck workers and partial bloom convergence.
[4.2.3] - 2026-05-08
Fixed
macula_peering_connserver-sideawaiting_startno longer drops racing QUIC events.macula_peering:accept/2transfers conn ownership and then castsstart_handshake; if the QUIC NIF redelivers a buffered{quic, new_stream, ...}or{quic, Bin, ...}event to the worker before the cast lands in its mailbox, the worker is still inawaiting_start. The previous wildcard clause routed those events throughdrop_unexpected/4and the bytes were lost; the worker then sat inhandshakingwith an empty buffer until its 30s timeout, never reachingtransition_to_connected. The peer's client-side worker meanwhile stayedconnected(it received our HELLO) so QUIC keep-alive papered over the asymmetry, but the listener-side never registered the peer in itspeersmap and the controlling-pid'sconnectednotification never fired — cross-station SUBSCRIBE / EVENT routing dead-ended.Verified live across the production Leuven mesh: every station had several stuck workers (
peer_node_id = undefined, buf_size = 0), and three of centrum's outbound peers had no corresponding inbound registration on the peer'speer_observer. The race was particularly brutal under fleet-wide reconnect bursts (post-roll).Fix: postpone QUIC events received in
awaiting_startso they re-deliver after thestart_handshaketransition intohandshaking, where the real handler consumes them.
[4.2.2] - 2026-05-08
Fixed
macula:find_record/2andmacula_station_link:find_record/3now pattern-match the wire-canonicalsignaturefield instead of the legacysigfield. The on-wire record format already usedsignature(seemacula_record:verify/1,macula_record:encode/1,macula_protocol_types:macula_record()), so the SDK was rejecting every successful DHT find with{error, {unexpected_reply, Record}}even though the relay had returned a perfectly valid record.Found while standing up the macula-e2e suite against the Leuven topology —
dht_put_findround-tripped end-to-end on the wire but the SDK swallowed the result.
[4.2.1] - 2026-05-08
Changed
Bumped QUIC
idle_timeout_msandkeep_alive_interval_msdefaults.macula_quic:listen/3:idle 120_000 → 300_000,keep_alive 30_000 → 15_000macula_quic:connect/4:idle 60_000 → 300_000,keep_alive 20_000 → 15_000
The realm's
MeshSubscriberclients were dying with:normalevery 50-60 s and respawning. Each cycle barely completed thefind_records_by_typesnapshot RPC before the underlying QUIC conn closed peer-side, which left the topology dashboard sparse (3 of 10 stations advertised at any moment instead of all 10).Root cause: client-side idle was 60 s and snapshot ticks happen on a longer cadence, so post-snapshot the conn went idle long enough for Quinn's idle-close to fire. Higher idle + more frequent PINGs closes the loophole. PING traffic also resets the peer's idle timer, so connections survive on either side's headroom.
Callers that explicitly pass
idle_timeout_msorkeep_alive_interval_msare unaffected.
[4.2.0] - 2026-05-08
Changed
{macula_peering, handshake_complete, ...}notification now carries the verifiedpeer_node_id. The message sent to a worker'saccept_ownerpid changed from{macula_peering, handshake_complete, ConnPid}to{macula_peering, handshake_complete, ConnPid, PeerNodeId}, wherePeerNodeIdis the Ed25519 pubkey extracted (and signature- verified) from the inbound CONNECT/HELLO frame.Lets accept-side listeners dedupe duplicate dials from the same peer identity. Without it, a peer that re-dials before its prior connection has been torn down (by client-side handshake timeout, network partition, or process restart) accumulates a fresh
connected-state worker on every retry. Production stations have been observed at 99 stuckconnectedworkers from a single sister-station because each dial completes the handshake, the prior worker holds its QUIC conn open until idle-timeout, and nothing dedupes them.See
macula-stationcommit pairing this release for the listener-side dedupe consumer.
Removed
- Yggdrasil module + sovereign-overlay
{pubkey, ...}dial form.macula_yggdrasiland themacula_quic:connect({pubkey, Pk}, ...)/macula_peering_conn:do_connect(#{pubkey := Pk})clauses are gone. No callers remain in the codebase;macula-netreplaces yggdrasil as the routing substrate. Self-signed pubkey-anchored cert generation (macula_quic:generate_self_signed_cert/3) stays — it has live consumers inmacula_net_transport_quicandmacula_station_listenerthat wrap an Ed25519 keypair without any Yggdrasil-derived address. - Dead test files.
test/macula_quic_tests.erl— tested the retiredquicer-style API surface (accept/2,recv/2,accept_stream/2, etc.) that the Quinn NIF does not expose.test/macula_quic_idle_timeout_tests.erl— testedquicerproplist option format.test/macula_yggdrasil_tests.erl— paired with the deleted module above.test/macula_client_test_server.erl— helper used only by the gateway tests below.test/macula_gateway_system/— entire directory, 13 test files, targeted the V1 gateway surface fully retired in 4.0.0.
Breaking
accept_ownerconsumers must update their pattern. Any code matching{macula_peering, handshake_complete, Pid}no longer matches; the message is now a 4-tuple. Match on{macula_peering, handshake_complete, Pid, _PeerNodeId}or use thePeerNodeIdfor dedupe.Only
macula_station_listenerinmacula-stationcurrently consumes this message; that consumer is updated in the paired release.No other behaviour change for callers that don't pass
accept_owner.
[4.1.1] - 2026-05-07
Fixed
- Handler returning
{error, _}no longer crashes the peering gen_statem. Pre-4.1.1,safe_invoke_handler/4inmacula_station_linkwrapped any non-crash return in aRESULTframe whosepayloadwas the raw return value. When a handler returned{error, Reason}(e.g._dht.put_recordreturning{error, bad_signature}for a record that fails verification), the resultingRESULTframe ended up atmacula_record_cbor:encode/1with a tuple as a payload value; the encoder has no clause for raw tuples and the peering state-machine terminated witherror:function_clauseat frame-sign time. Every other multiplexed RPC on the same QUIC connection died with it. This bit production immediately when station↔station DHT replication started shipping records that failed downstream verification: each replication attempt killed the connection that any nearby caller (including realm topology subscribers) was multiplexed onto.Now{error, Reason}is funneled into a BOLT#4call_errorframe withcode = 0x0F unknown_erroranddetailset to the~p-formatted Reason (capped at 256 bytes). Handler crashes continue to map tocode = 0x02 temporary_relay_failure. Thenormalise_reply/1function lost its now-dead{error, _}clause.Existing testinbound_call_handler_error_tuple_passes_through_as_result_test_asserted the buggy shape and was renamed toinbound_call_handler_error_tuple_emits_call_error_test_with updated expectations: the test now demands acall_errorframe with code0x0Fand a binarydetailthat includes the formatted Reason. 35 station_link eunit tests still pass; full suite parity (1622 passed / 10 pre-existing failures, unchanged).Affected files:src/client/macula_station_link.erl—safe_invoke_handler/4,normalise_reply/1, new helperformat_error_detail/1test/macula_station_link_tests.erl— test rename + body
[4.1.0] - 2026-05-06
Added
accept_owneropt onmacula_peering:accept/2andconnect/1— optional pid that receives a single{macula_peering, handshake_complete, ConnPid}message the moment the worker transitions fromhandshakingtoconnected. Distinct fromcontrolling_pid, which receives theconnected/frame/disconnectedevent stream. Lets an accept-side listener cap concurrent handshaking workers separately from healthy connected peers — the original intent of the cap, before stub fan-out filled it with verified peers and starved station↔station handshakes (see macula-station 4beb2f5 for the matching cap-bump fallback).
Notes
- Pure addition; no behaviour change for callers that don't pass
accept_owner.
[4.0.0] - 2026-05-06
Major release. Breaking. V1 surface fully retired; pool-aware
streaming RPC ships; the macula_stream_v1 module renamed.
Removed
- macula_mesh_client — V1 single-connection client. Deleted.
- macula_multi_relay — V1 multi-relay wrapper. Deleted.
- V1 facade entry points on macula.erl — every form taking a
V1 client pid as its first argument:
disconnect- V1 client-pid forms of
subscribe,publish,unsubscribe,call,advertise,unadvertise - V1 REMOTE forms of
call_streamandadvertise_stream(LOCAL in-process forms preserved) - V1 client-pid forms of
put_record,find_record,find_records_by_type,subscribe_records,unsubscribe_records— replaced with V2-shaped entries on the same names (see Changed) - The entire V1 directed-RPC block:
call_node,resolve,list_nodes - The
client/0type alias
- V1 carrier branch in macula_stream — the
{remote, _, _}peer shape,attach_remote/3export, andsend_remote/4dispatch path are gone. The module now spans only LOCAL in-process pairs and V2 station-link carriers. - V1 test files:
macula_mesh_client_validate_tests.erl,macula_multi_relay_tests.erl,macula_stream_remote_tests.erl.
Net deletion: ~2700 LOC.
Added — pool-aware streaming RPC (A4)
Streaming RPC now rides the V2 pool. Five new STREAM_* wire frames
(stream_open, stream_data, stream_end, stream_error,
stream_reply) in macula_frame, plus per-station and pool
surfaces:
macula:call_stream/5— open a stream against a V2 pool. Sticky-to-link: the returned stream is bound to the link the pool picked; if that link dies the stream errors withpeer_downand the caller re-opens.macula:advertise_stream/5— fan-out streaming-procedure registration across every healthy link in the pool. Replayed on link respawn.macula:unadvertise_stream/3— drop a streaming advertisement.- Per-link API on
macula_station_link:call_stream/5,advertise_stream/5,unadvertise_stream/3,send_stream_frame/3. - Pool API on
macula_client:call_stream/5,advertise_stream/5,unadvertise_stream/3. Plus an internal replay helper that re-issues stream advertisements when a link respawns.
29 new eunit tests cover frame round-trips, per-station gating, pool fan-out, replay, and disconnect cleanup.
Changed — DHT entries
put_record / find_record / find_records_by_type / subscribe_records / unsubscribe_records keep their names but now
take a V2 pool as the first argument (was a V1 client pid). DHT
traffic travels under the all-zeros realm tag
(?DHT_REALM = <<0:256>>), the SDK convention for
protocol-internal infrastructure traffic.
Changed — macula_stream rename
The macula_stream_v1 module is renamed to macula_stream. The
"v1" suffix referred to the V1 wire format the gen_server originally
bridged via macula_mesh_client; A4 extended the same gen_server
to carry V2 streams as well, and the V1 retirement removed the
mesh_client carrier entirely. The module now spans LOCAL pairs and
V2 station-link pairs only — the suffix had become misleading.
External consumers using macula_stream_v1:* directly must rename
to macula_stream:*. No semantic change.
Changed — macula_dist_relay ported to V2 pool
Erlang-distribution-over-mesh stays. Its plumbing moves from V1
macula_mesh_client / macula_multi_relay to the V2
macula_client pool.
register_mesh_client / get_mesh_clientonmacula_dist_relayrenamed toregister_mesh_pool / get_mesh_pool.persistent_termkeymacula_dist_mesh_client→macula_dist_mesh_pool.extract_payloadonmacula_dist_relaydeleted; V2 events deliver Payload directly in the message tuple, no map-or-binary unpacking needed.macula_dist_bridgestate fieldclient / client_mon→pool / pool_mon; args map keyclient => Client→pool => Pool
Realm tag: dist tunnel frames travel under the all-zeros realm (matches the DHT convention; protocol-internal infrastructure).
Migration
Workspace consumers that referenced V1 (hecate-daemon, hecate-app-weather, mesh_chat) were ported in lockstep across their respective repositories before this release; nothing in the canonical workspace should break on the bump.
External consumers must:
- Replace
macula:connect/2call-sites that destructured the result as a V1 client. The handle is now a pool. - Add a 32-byte realm tag to every
subscribe,publish,call,advertise,unadvertise,call_stream,advertise_stream,unadvertise_streamcall-site. Usemacula_realm:id(BinaryName)(SHA-256) or your own derivation. - Switch pubsub callbacks to pid-receivers. V2 delivers
{macula_event, SubRef, Topic, Payload, Meta}to a pid; the former 1-arg callback shape is available viamacula:subscribe_callback/4if you need to keep callback semantics. - Rename
macula_stream_v1:*→macula_stream:*if your code reached past the facade.
See docs/migrations/V1_TO_V2_PUBSUB.md for detailed examples.
[3.16.0] - 2026-05-06
Daemon-driven additive release. Five SDK gaps surfaced during the
hecate-daemon V1→V2 migration drafting (PLAN_DAEMON_V2_MIGRATION.md
in hecate-daemon) land here as purely additive APIs. No breaking
change; every 3.15.x consumer continues to work unchanged.
The remaining gap (pool-aware streaming RPC) is deferred to 3.17.0
along with the full SDK quality sweep. See
docs/PLAN_SDK_3_17.md for the deferred scope.
Added
macula:status/1andmacula_client:status/1— aggregate health snapshot of a V2 pool. Returns a map withseeds,healthy_links,failed_links,self_node_id, andsubscriptions. Single round- trip plus oneis_connectedprobe per spawned link (each capped at 1s by the link's own gen_server). Suitable for/healthor/statusendpoints.macula:subscribe_callback/4andmacula_pubsub:subscribe_callback/4— callback-shim atop the message-basedsubscribe/4. Spawns a small monitored receiver internally; invokes the callback once per inbound event. A crashing callback is logged and the next event is delivered (rationale: a transient bug in event handler N must not lose events N+1..M). Receiver exits when the caller dies orunsubscribe/2clears the sub.Pool-aware non-streaming RPC:
macula:call/5— first-success across the pool's healthy links. Returns{error, no_healthy_station}when no link has completedCONNECT/HELLO. Per-link errors fall through to the next.macula:advertise/5— fan-out advertise on every healthy link AND store in pool state for replay on link respawn. Arity 5 to avoid colliding with the legacy V1advertise/4.macula:unadvertise/3— best-effort fan-out drop, always clears local state.- macula_client_replay:advs_to/2 — advs replay helper, mirrors the existing subs_to/2.
macula_client:opts()type spec gained per-key documentation. V1-only opts (relays,realm,site,connections) trigger a one-shotlogger:noticelisting the silently-ignored keys when a caller passes them; the pool boots normally. Seemacula:connect/2for the full V1→V2 opts mapping.macula_clientre-exports thehandler()type. Avoids consumers reaching into the privatemacula_station_linkmodule.
Documentation
macula:connect/2doc gained a "V1-only opts" section calling out each silently-ignored key with its V2 equivalent.macula_client:opts()andmacula_client:status/1documented per key / per field.macula_pubsub:subscribe_callback/4documented including the callback-crash semantics.
Tests
19 new eunit tests across macula_client_tests and
macula_pubsub_tests:
- 4 for
status/1(empty pool, unreachable seeds, subscription count, facade delegation) - 4 for
subscribe_callback/4(happy path, callback-crash survival, arity guard, caller-death cleanup) - 7 for pool RPC (
call/5,advertise/4,unadvertise/3, facade delegation, handler-arity guard) - 1 for V1-legacy opt warning
- 2 for dedup window/sweep tunable end-to-end
Verification
rebar3 compile— cleanrebar3 dialyzer— clean (89 files)rebar3 ex_doc— exit 0 (2 cosmetic warnings about historical CHANGELOG entries with underscored module names tripping ex_doc's italic parser; they do not affect any post-3.11 entry or any API surface)- All 743 baseline tests still pass; one pre-existing teardown flake
(
macula_multi_relay_tests:status_test/stop_test/1) unchanged.
[3.15.3] - 2026-05-05
Fixed
macula_peering_conn:on_handshake_enter_client/2crashed withbadmatchwhenmacula_quic:setopt/3ormacula_quic:send/2returned{error, _}. This is a normal race: the QUIC connection can die betweennif_connectreturning{ok, Conn}and the client gen_statem entering itshandshakingstate (peer closes, network drops, server sendsCONNECTION_CLOSEafter TLS but before the first stream). Pre-3.15.3 this crashed the peering_conn supervisor child with abadmatch {error, <<"connection lost">>}and dumped a stacktrace per attempt. Now: surfacedisconnectedwith a structured{setopt_failed | send_connect_failed, Reason}and let the caller schedule a reconnect via the standard backoff path.Discovered during BE station fleet on falkenstein (2026-05-05) — every concurrent outbound dial that completed the TLS handshake but then failed at the application layer crashed the gen_statem and accumulated SUPERVISOR crash reports.
macula_quic:setopt/3spec widened fromoktook | {error, term()}. The NIF surfaces errors when the stream handle is stale or invalid; the narrow spec made dialyzer reject defensive{error, _}matches in callers.
[3.15.2] - 2026-05-05
Fixed
macula_station_linkSDK specs widened to admit{error, term()}returns. The wrappers aroundgen_server:call/3(subscribe/4,unsubscribe/2,advertise/4,unadvertise/3) declared narrow return types ({ok, reference()}/ok) but in reality dispatch to an arbitrarypid()and surface{error, unknown_call}(or any other reply) when the target gen_server does not implement the call. Callers that pattern-matched only the success shape in atry ... of(no wildcard) crashed withtry_clause— silent bug until consumers passed non-conforming pids alongside SDK link clients (e.g. macula-station's seed-dial outbound link workers). Now:subscribe/4 -> {ok, reference()} | {error, term()} unsubscribe/2 -> ok | {error, term()} advertise/4 -> ok | {error, term()} unadvertise/3 -> ok | {error, term()}No runtime behaviour change — these are spec-only widenings. Consumers should add a wildcard
_Other -> ...clause when pattern-matching the return value, sincetry ... of {ok, X} -> ... catch _:_ -> ... enddoes NOT catch thetry_clauseexception raised by an unmatchedofpattern.
[3.15.1] - 2026-05-02
Fixed
macula_quic:nif_connect/8rejected every call withbadarg. The Rust signature tookverify_pubkey: Vec<u8>but rustler'sVec<T>decoder only accepts list terms, never binaries — so every caller passing a binary (which is every caller) blew up at the decode boundary. Switched toBinary<`a>mirroringcert.rs:nif_generate_self_signed_cert. Affects everymacula_quic:connect/4user, not just macula-net.macula_net_transport_quicignored every inbound stream byte: the data-arrival pattern matched{quic, data, Stream, Data}, but the NIF emits{quic, Binary, StreamRef, Flags}(mirroring quicer's shape — seenative/macula_quic/src/message.rs). Fixed the clause guard.
Added
test/macula_net_transport_quic_e2e_tests.erl— two-node QUIC envelope round-trip viapeer:start_link/1. Catches both bugs above.test/macula_net_full_stack_e2e_tests.erl— full pipeline: node Amacula_route_packet:dispatch→ QUIC → node Bmacula_deliver_packet:handle_envelope→ captured payload, asserted byte-identical.
[3.15.0] - 2026-05-02
Added — macula-net L3 substrate (Phase 1)
First slice of the sovereign-IPv6 substrate per PLAN_MACULA_NET.md
(macula-architecture). Macula now owns its own crypto-derived IPv6
addressing layer; identities (stations + daemons) become first-class
endpoints in the host's standard networking stack.
New slices in src/:
derive_address/macula_address— pubkey -> IPv6 (BLAKE3, ULA prefix). Reusesmacula_blake3_nif; no new NIF.manage_tun_device/macula_tun+macula_tun_nif— Linux TUN lifecycle + packet I/O via Rust NIF (tun-rs). Reader thread pumps packets to a registered BEAM Pid as{macula_net_packet, ...}messages.route_packet/— egress.macula_route_packet_ipv6parses the IPv6 fixed header;macula_route_packetlooks up dst in a static station table and dispatches the CBOR envelope to the station's transport callback.deliver_packet/macula_deliver_packet— ingress. Decodes the CBOR envelope (viamacula_cbor_nif), validates, writes inner IPv6 packet to the local TUN if dst is local.macula_net/— facade +macula_net_transportbehaviour +macula_net_transport_quic(Quinn-based, uses the SDK's existingmacula_quicprimitives — no new QUIC NIF).
New native crate: native/macula_tun_nif/ (rustler 0.34, tun-rs 2).
Linux only for Phase 1.
29 new eunit tests across the slices; all existing tests pass.
Phase 1 simplifications (deferred to Phase 4 hardening): static station table (no DHT yet — Phase 2), single-hop only, self-signed throwaway TLS certs, ctrl/gossip envelope types accepted but not handled.
The repo macula-io/macula-net (where this work was prototyped) has
been folded into this SDK and deleted.
[3.14.0] - 2026-05-02
Added — Sovereign-overlay (Yggdrasil) building blocks
Phase 1 Tier 3 of the sovereign-overlay rollout — see
PLAN_SOVEREIGN_OVERLAY_PHASE1.md (macula-architecture) §4.2-§4.4.
This release delivers the SDK-side primitives that let stations
present, and daemons validate, a pubkey-anchored QUIC identity
with no DNS, no Let's Encrypt, no CA chain.
New module macula_yggdrasil:
address_for/1— derive the Yggdrasil IPv6 (200::/7) from a raw 32-byte Ed25519 pubkey. Matches yggdrasil-go'sAddrForKeyreference exactly. Verified against the live 3-relay fleet's pubkeys/addresses (Helsinki, Nuremberg, Paris).format_address/1— 16-byte IPv6 binary → canonical colon-separated string.cert_for/1,2— generate a self-signed X.509 cert wrapping an Ed25519 keypair. The derived Yggdrasil IPv6 lands as IP SAN; optional extra DNS SANs supported. Cert validity 10 years.
NIF additions in macula_quic (Quinn QUIC):
generate_self_signed_cert/3viarcgen0.13. Takes raw Ed25519 pubkey + secret seed + SAN list, returns{ok, {CertPem, KeyPem}}.PubkeyPinVerifier— rustlsServerCertVerifierimpl that pins on the leaf cert's Ed25519 SubjectPublicKeyInfo rather than walking a CA chain. Equivalent of TLS RFC 7250 raw-public-key without the wire-protocol change.build_client_configgainsOption<Vec<u8>> pinned_pubkey. None preserves existing webpki/skip behaviour.
Erlang dial-target syntax extension:
macula_quic:connect/4accepts{pubkey, Pk32 :: binary()}as a target in addition to the existing host string. Derives the Yggdrasil IPv6, sets the verify_pubkey opt, dispatches through the standard nif_connect path.macula_peering_conn:do_connectrecognises the same shape via apubkeykey on the target map.
NIF connection layer:
nif_connectnow takes an additionalverify_pubkey: Vec<u8>parameter (arity 7 → 8). Empty binary disables pinning.[ipv6]:porthost strings are supported via bracket-stripping beforelookup_hostand SNI assignment.
Notes for downstream consumers
macula_quic:connect/4ABI is unchanged; the newverify_pubkeyopt is opt-in, defaults to<<>>.nif_connectarity bumped 7 → 8. Anyone shipping a NIF .so built against the 3.13 Erlang module needs to ship the 3.14.sotogether. Mixing produces{bad_lib, "Function not found macula_quic:nif_connect/7"}on load.- New crate deps in
macula_quic: rcgen 0.13 (pem+ring), x509-parser 0.16, time 0.3.
[3.13.0] - 2026-04-28
Added — V2 ADVERTISE/UNADVERTISE wire frames + station_link advertise API
Closes the V2-fleet fresh-install blocker. macula-realm could not
register RPC procedures over the V2 wire because the protocol only
exposed CALL/RESULT/ERROR. Realms had to keep advertising via V1
:macula.advertise, but V1 frames are silently dropped by V2
listeners (visible as _realm.membership.join_with_token_v1 hanging
on every fresh daemon's join).
macula_frame gains two new frame types:
advertise/1—(realm, procedure, advertiser, options), signed by the advertiser. The connected station registers(realm, procedure)in its per-connection routing table so inbound CALL frames matching that key are forwarded back across the advertiser's QUIC connection.unadvertise/1—(realm, procedure, advertiser). Drops the registration. Idempotent. Implicit on peer disconnect (the station'speer_observerpurges every entry whoseconn_pidequals the dropped connection).
macula_station_link gains:
advertise/4—(Pid, Realm, Procedure, Handler). Registers the handler locally and sends an ADVERTISE frame on the wire. Queued until HELLO completes (drained onconnectedalongside pending subscribes). Handler signature mirrorshecate_handler_dispatch:{ok, Reply}/{error, Reason}/ bare value, with crash trap mapping to BOLT#4temporary_relay_failure(0x02).unadvertise/3—(Pid, Realm, Procedure). Best-effort wire frame, always clears the local handler.- Inbound CALL handling:
(realm, procedure)matched against the local procedure map, dispatched, RESULT/ERROR shipped back. An unmatched procedure produces a signedunknown_next_peer(0x01) reply. - Replay on reconnect: every advertised procedure re-emits ADVERTISE
on
(Pid, connected, ...), mirroringdrain_pending_subscribes.
Wire frame round-trip and SDK behaviour covered by 13 new tests (7 station_link + 6 frame). All 122 frame tests + 26 station_link tests pass; dialyzer clean.
The companion station-side routing lives in
hecate-station (renamed to macula-station 2026-04-30):
new hecate_remote_advertise_registry plus modifications to
hecate_station_peer_observer to forward CALLs across the
advertiser's connection and relay RESULT/ERROR back.
[3.12.1] - 2026-04-28
Fixed — macula_station_link:call/5 gated on completed handshake
The {call, ...} gen_server clause was gated on peer_pid, which
is set the moment macula_peering:connect/1 returns — before the
peering worker has finished the CONNECT/HELLO handshake. The
matching {publish, ...} clause is correctly gated on peer_node_id
(set by the {macula_peering, connected, ...} notification after
HELLO).
The race: a caller (e.g. a freshly-spawned daemon stub) issues
put_record/3 immediately after start_link/1. The link forwards
the call frame via macula_peering:send_frame/2 =
gen_statem:cast(PeerPid, {send_frame, Frame}) while the peering
worker is still in handshaking. The handshaking state has no
clause for cast({send_frame, _}), so the cast falls into
drop_unexpected/4 and the frame is silently dropped. The caller's
deadline timer eventually fires and surfaces {error, timeout},
even though the underlying QUIC connection is healthy and any
subsequent call (after the timer's wake-up) would have succeeded.
The fix gates {call, ...} on peer_node_id to match {publish, ...}.
Callers that issue a request before the handshake completes now get
{error, not_connected} immediately, matching the SDK's documented
contract for the disconnected case. Existing call sites (e.g.
hecate_stub_daemon) already handle {error, not_connected} with a
short backoff, so no consumer change is required.
Direct evidence of the bug from the production fleet — handshaking
peering_conn workers on relay boxes carry buffers that successfully
parse as V1 wire frames (a separate problem in hecate-daemon's
unfixed realm-join path), but the V2-protocol stub workers also
showed timeout-then-recycle cycles on every put_record.
[3.12.0] - 2026-04-28
Added — peers opt on node_record/4 for overlay topology
macula_record:node_record_opts() now accepts an optional peers
field — a list of 32-byte pubkey binaries identifying the stations
this node currently has an active overlay session with.
When non-empty (undefined or [] keep the field absent), the list
is dropped into the canonical CBOR payload at
{text, <<"peers">>} after lists:usort/1 deduplication + sort. The
deterministic ordering preserves the signature-stable property of the
existing canonical form: the same set of peers always encodes to
identical bytes regardless of insertion order.
Records that omit the field (older publishers, daemons, anyone who
doesn't supply peers) round-trip exactly as before — the new
clause in node_payload/5 is a no-op when the opt is absent.
Consumers (e.g. realm topology dashboards) join the list against
their station view to draw relay-to-relay edges without a
side-channel topology poll. hecate-station 896d6b5+ populates the
field at announce time from each per-identity hecate_station_peer_observer.
[3.11.1] - 2026-04-27
Fixed — macula_record_cbor:encode/1 accepts atoms
encode/1 previously crashed with function_clause when handed a
map containing atom keys. In production this manifested when the
station's _dht.put_record handler called macula_record:verify/1
on a wire-decoded record:
- macula_frame:from_wire_envelope/1 atomizes binary keys via
binary_to_existing_atom/1(the safe variant — only known atoms become atoms; unknown ones stay as{text, Bin}or binary). - Recognised payload keys like
hostname,endpoint,kind,node_id,city,country,lat,lng,capabilitiesare all SDK-level atoms (declared innode_payload/5), so they DID get atomized. verify/1then re-encodes the envelope for signature check, walking the payload sub-map. The encoder'sfunction_clausefired at the first atom key, the handler crashed, and the daemon's announcer saw{call_error, 2, temporary_relay_failure}on every refresh.
The fix adds a clause encode(A) when is_atom(A) -> ... that emits
the atom's UTF-8 name as a major-3 text string. By the symmetry of
atom_to_binary/1 / binary_to_existing_atom/1 the resulting wire
bytes are byte-for-byte identical to the original record's encoding,
so signature verification succeeds.
null retains its dedicated <<16#F6>> clause (major-7 simple
value); the atom clause is matched only after null.
Tests
- 4 new EUnit cases in
macula_record_cbor_tests:encode_atom_emits_text_string_testencode_atom_in_map_keys_testencode_null_still_uses_simple_value_testverify_round_trip_with_atomized_payload_test(fullnode_recordbuild → sign → atomize-keys (mimicking maculaframe:from_wire_envelope) → verify returns `{ok, }`).
Consumer impact
hecate-station, hecate-daemon, and macula-realm all pin
{macula, "~> 3.11.0"}, so 3.11.1 is auto-allowed; refresh each
consumer's lock (rm rebar.lock or mix deps.update macula) and
push to trigger a rebuild.
[3.11.0] - 2026-04-27 — Phase 1 of PLAN_V2_PARITY
Added — macula_client pool (canonical V2 client handle)
src/client/macula_client.erl is the new canonical SDK client. It
holds N peering links to N stations and routes ops with replication,
subscription replay, and inbound-event dedup. Apps no longer manage
individual macula_station_link workers — they call
macula_client (or the macula facade, which re-exports the same
surface).
Public API: connect/2, close/1, child_spec/3, publish/5,
subscribe/5, unsubscribe/2. See
docs/guides/CONNECTING_GUIDE.md.
The pool uses one shared identity across all links: stations see
the pool as a single peer (one pubkey across N links). Inbound
EVENT frames are deduped by (Realm, Publisher, Seq) over a
60s-default sliding window. replication_factor (default 1) fans
each PUBLISH to N healthy links — partial success counts as
success.
Decomposed across three files:
macula_client.erl— gen_server + public API + bookkeepingmacula_client_dedup.erl— ETS dedup keyed by{realm, publisher, seq}macula_client_replay.erl— sub replay on link respawn
Added — macula_pubsub slice module
src/pubsub/macula_pubsub.erl is the pub/sub-specific surface:
publish/4, publish/5, subscribe/4, subscribe/5,
unsubscribe/2. Thin delegation over macula_client with
realm-per-call guards. Apps may import the slice directly or call
through the macula facade.
Changed — realm-per-call (macula_station_link)
macula_station_link now requires the 32-byte realm tag per
operation rather than as a connect-time option. Stations are
realm-agnostic infrastructure; the realm travels in every wire
frame. API:
call/4→call/5(Realm between Pid and Procedure)subscribe/3→subscribe/4(Realm between Pid and Topic)- new
publish/4(fire-and-forget, requires full handshake) - DHT wrappers (
put_record,find_record,find_records_by_type) keep their shape; route under the all-zeros realm tag internally.
This is a breaking change for any direct consumer of
macula_station_link. Pool consumers (macula_client) absorb the
change.
Changed — macula facade V2 surface
The facade is rewired with V2 functions on the same surfaces that were V1:
connect/2— now returns a V2 pool (was: V1macula_mesh_client)publish/4— now(Pool, Realm, Topic, Payload)(was: V1(Client, Topic, Data, Opts))unsubscribe/2— now routes tomacula_client(V2 pool)
New on the facade:
close/1,child_spec/3publish/5,subscribe/4,subscribe/5
V1 facade surfaces are otherwise untouched: subscribe/3,
publish/3, disconnect/1, call/3,4, advertise/3,4,
unadvertise/2, put_record/2, find_record/2,
find_records_by_type/2, plus all stream + directed-RPC
operations.
Renamed — close/1 → close_stream/1 for V1 streams
macula:close/1 previously closed a V1 stream pid; in 3.11.0 it
closes a V2 pool. The V1 stream-close moves to
macula:close_stream/1. macula:close_send/1 (half-close) is
unchanged. Audit every callsite of macula:close/1 before
upgrading — the arity is identical so the compiler accepts both
shapes silently. See docs/migrations/V1_TO_V2_PUBSUB.md.
Added — docs
docs/guides/CONNECTING_GUIDE.md— pool model, seeds, identity, replication, lifecycle,child_spec/3integration.docs/guides/PUBSUB_GUIDE.md— rewritten for V2: realm-per-call subscribe/publish, dedup, EVENT delivery, message format.docs/migrations/V1_TO_V2_PUBSUB.md— what broke, before/after snippets, two migration paths (adopt V2 vs keep V1 viamacula_mesh_clientdirect-module calls).
Deferred to Phase 2 — macula_auth
The Phase 1 handover plan called for landing macula_auth types +
{not_implemented, phase_2} stubs. That conflicts with the SDK's
CLAUDE.md rule "NO TODO STUBS — Code Must Be Functional." Per
that rule, macula_auth is not included in 3.11.0 and is now
a hard gate item for Phase 2: full mint/delegate/verify/
prove/list_capabilities/token_id over macula_ucan_nif. See
~/.claude/plans/PLAN_V2_PARITY.md §15a for the deferral record.
Tests
- 685 eunit / 0 fail (was 658 in 3.10.3).
- New:
macula_client_tests(10 cases),macula_client_dedup_tests(8 cases),macula_pubsub_tests(4 cases),macula_facade_tests(4 cases). - Updated:
macula_station_link_tests— 19 cases (+4 new for realm isolation + publish/4 success + publish/4 not_connected guard). - Removed three V1-facade test files superseded by the new V2
tests:
macula_client_SUITE,macula_client_integration_SUITE,macula_client_pubsub_tests. V1 still covered by direct-module testsmacula_mesh_client_validate_tests+macula_multi_relay_tests.
[3.10.3] - 2026-04-27
Fixed — handshaking state now times out after 30s
macula_peering_conn added a state_timeout on the handshaking
state. If CONNECT/HELLO does not complete within 30 seconds the
worker emits a _macula.peering.handshake_timeout diagnostic and
exits cleanly.
Without this, peers speaking the wrong wire format (e.g. V1 daemon
clients dialling V2 stations) leave workers stuck in handshaking
indefinitely, accumulating bytes in the per-worker buffer that
never form a valid V2 frame. Production observed 1000+ such workers
per relay box (PLAN_FLYING_RESTART).
The diagnostic carries role, buf_size, has_stream and
timeout_ms so operators can correlate with V1/V2 protocol mismatch.
This pairs with the per-identity peering cap added on the
hecate_station_listener side (cap blocks unbounded NEW connections;
this timeout drains the EXISTING stuck pool).
[3.10.2] - 2026-04-27
Fixed — subscribe/3 now queues until peering connects
macula_station_client:subscribe/3 used to return
{error, not_connected} when called before the peering
CONNECT/HELLO completed — the typical pattern for any consumer
that subscribes immediately after start_link/1. The wire frame
never went out, the consumer's mailbox stayed silent, and the
station never saw the subscriber.
3.10.2 stores the subscription state immediately and returns
{ok, SubRef} regardless of connection state. The wire-level
SUBSCRIBE goes out either right then (already connected) or via
a drain on the connected peering event (handshake completes
later). Disconnect still drops every subscription the same way it
always did — the queue lives only across the handshake, not
across reconnects.
Tests
- 1 new EUnit case covering the subscribe-before-connect path: subscribe immediately after start_link, inject the connected event, capture the SUBSCRIBE frame on the wire.
[3.10.1] - 2026-04-26
Added — kind field on node_record
macula_record:node_record/4 now accepts an optional kind opt,
emitted into the payload as {text, <<"kind">>} => {text, Bin}.
Stations set it to <<"station">>; daemons (Part 4 of the
DHT-first topology integration in hecate-station / hecate-daemon)
set it to <<"daemon">>. The discriminator lets subscribers route
presence facts on distinct mesh channels (_mesh.station.* vs
_mesh.daemon.*) without inferring actor type from capability
bits.
Records without kind predate the field. Consumers default the
missing field to <<"station">> since stations were the only
producers prior to 3.10.1.
Tests
macula_record_testsnow covers thekindfield via two cases —node_record_with_kind_field_test(presence) and the existingnode_record_omits_unset_optional_fields_test(absence). 67 cases total, all pass.
[3.10.0] - 2026-04-26
Added — streaming subscribe on macula_station_client
The station-client now exposes a pubsub surface alongside the
existing request/response (call/4, put_record/2,3,
find_record/2,3, find_records_by_type/2,3):
subscribe/3— sends a SUBSCRIBE frame to the connected station and registers a delivery pid. Returns{ok, SubRef}. The subscriber receives{macula_event, SubRef, Topic, Payload, Meta}for every matching EVENT frame the station fans out, and a single{macula_event_gone, SubRef, Reason}when the connection drops or the client stops.unsubscribe/2— sends a best-effort UNSUBSCRIBE frame and clears local bookkeeping. Idempotent.
The client monitors each subscriber pid; if it dies the
subscription is cleaned up and a best-effort UNSUBSCRIBE goes on
the wire. On disconnect every active subscription receives one
macula_event_gone so consumers can react without polling
is_connected/1.
This unblocks topology aggregators (e.g. macula-realm) that need to
hear about new DHT records as they land, instead of polling
find_records_by_type and only ever seeing the seed station's
local cache.
Tests
- 5 new EUnit cases:
subscribe_sends_frame,event_frame_delivered_to_subscriber,unsubscribe_sends_frame_and_clears,subscriber_down_drops_subscription,disconnect_notifies_subscribers. - Total
macula_station_client_testscount: 15. All pass.
[3.9.0] - 2026-04-26
Added — DHT writes via V2 station-client
Round out macula_station_client so it can drive every DHT operation
a node needs against a V2 station, not just reads:
put_record/2,3— wraps_dht.put_record. Returnsokon aRESULT(ok)reply,{error, {unexpected_reply, _}}on any other payload,{error, timeout}/{error, {disconnected, _}}per the existingcall/4taxonomy. Stations replicate the put across the K-nearest peers in their Kademlia routing table, so a single call against any one connected station propagates to the rest of the DHT.find_record/2,3— wraps_dht.find_record. Returns{ok, Record}for a signed record map,{error, not_found}for aRESULT(not_found)reply.
This closes the gap that left node daemons unable to publish
node_record / domain-fact records into V2-only stations:
macula_mesh_client (V1) speaks the V1 wire and is rejected by
hecate-station's V2 peering listener, so before this release writes
silently dropped. Consumers (hecate-daemon, future SDK clients) now
have a single read+write path through macula_station_client.
Tests
- 4 new EUnit cases:
put_record_ok,put_record_unexpected_reply,find_record_ok,find_record_not_found. - Total
macula_station_client_testscount: 10. All pass.
[3.8.0] - 2026-04-26
Added — V2 station-client (macula_station_client)
A high-level outbound RPC client for V2 stations, built on top of the
macula_peering state machine and macula_frame CALL/RESULT/ERROR
frames vendored in 3.6.0–3.7.0.
macula_station_client:start_link/1— spawn agen_serverthat owns onemacula_peeringconnection to a single station endpoint and drives the CONNECT/HELLO handshake as the client side.macula_station_client:call/4— issue a CALL frame and block until the station replies, the deadline elapses, or the connection drops. RESULT/ERROR frames are matched against pending callers via the 16-bytecall_id.macula_station_client:find_records_by_type/2,3— convenience wrapper for the_dht.find_records_by_typeprocedure that any station with the standard handler registry exposes.
This bridges a real protocol gap: V1 consumers (macula_mesh_client)
cannot drive V2 stations because V2 stations dispatch the QUIC
connection straight into macula_peering:accept/2, so V1 CONNECT
frames never reach the V2 handler registry. Until 3.8.0, an SDK user
who wanted to query a deployed station for its DHT records had to
re-implement the V2 client surface from scratch (the realm topology
subscriber in macula-realm hit exactly this).
Tests
Six new EUnit tests cover seed parsing, CALL frame construction,
RESULT/ERROR matching by call_id, deadline expiry, and connection
drop. The live QUIC handshake against a real V2 station is exercised
in hecate-station's CT suites.
[3.7.0] - 2026-04-26
Added — peering state machine + diagnostics primitives
Two more modules vendored from hecate-station as the canonical SDK
implementation, finishing the V2 fork mop-up alongside macula_frame
in 3.6.0:
macula_peering+macula_peering_conn+macula_peering_sup+macula_peering_conn_sup— per-peer connection state machine (CONNECT / HELLO handshake, frame send/receive, GOODBYE drain). Onemacula_peering_conngen_statem per peer, supervised bymacula_peering_conn_supundermacula_peering_sup. The top supervisor is started bymacula_rootwhen the SDK boots, soapplication:ensure_all_started(macula)registers bothmacula_peering_supandmacula_peering_conn_sup.macula_diagnostics— structured event emission via OTPlogger- per-process counter / gauge metrics. Phase 1 implementation; upgrades to Prometheus / OpenTelemetry exporters land in Phase 7 without changing the public surface.
Changed — peering uses macula_quic directly
The vendored peering modules call macula_quic directly (positional
args + opts list) rather than going through an option-map adapter.
Peering's caller-facing target opt is still a map
(#{host, port, alpn?, timeout_ms?}), unpacked inside
macula_peering_conn before
dispatching to macula_quic:connect/4. Result: one transport layer
in the SDK, no adapter-on-adapter.
The hecate-station-internal hecate_transport adapter survives in
hecate-station for that repo's own listener / server modules — those
keep their option-map calling style.
Fixed — EDoc cleanups in vendored modules
rebar3 ex_doc now runs clean. Affected modules vendored in 3.6.0
plus the new ones from 3.7.0:
- Markdown-style paired backticks (
`text`) replaced with the EDoc-native form (`text`) inmacula_frame,macula_source_route,macula_bolt4,macula_peering*andmacula_diagnostics. EDoc does not support markdown backticks. - Binary syntax (
<<...>>) inside<pre>blocks inmacula_frameHTML-escaped to<<...>>— the EDoc XML parser was consuming<<as the start of a tag.
[3.6.0] - 2026-04-26
Added — Macula V2 frame primitives (CBOR wire)
Three new modules vendored into the SDK as the canonical implementation for hecate-station and any future Macula V2 service:
macula_frame— CONNECT / HELLO / GOODBYE, SWIM (ping / ack / suspect / confirm / update), DHT (ping / pong / find_node / nodes / find_value / value / store / store_ack / replicate / replicate_ack), CALL / RESULT / ERROR (Part 6 §5), HyParView, Plumtree, PubSub, content transfer. Length-prefixed deterministic CBOR (RFC 8949 §4.2.1) per Part 6 §3.macula_bolt4— BOLT#4-style error-code taxonomy used bymacula_frame:call_error/1and friends.macula_source_route— onion-style source-route header builders plus the rotation helpers feature gates.
Atom-keyed in-process maps round-trip transparently:
- Encode walks the map, converting atoms to text strings via
atom_to_binary/2; floats stringify compactly; integers, binaries and lists pass through unchanged. - Decode walks the decoded CBOR term and restores atoms via
binary_to_existing_atom/2(safe — never grows the atom table from untrusted input). - Records (
record,recordsfields) delegate tomacula_record:encode/1so the SDK's canonical CBOR shape is preserved verbatim across the wire.
This unifies the two parallel implementations that had diverged into
the deferred macula-v2 umbrella branch (apps/macula_frame/) and into
hecate-station (apps/hecate_frame/). Both implementations were
byte-identical BERT before this commit; both consumers now depend on
the SDK module instead.
PLAN_WIRE_CBOR.md (hecate-station) drove this — the macula 3.x mesh
client speaks CBOR per Part 6 §3 but hecate-station was on BERT, and
the wire incompatibility silently dropped every cross-codec frame.
With both sides on this macula_frame, station<->station and
station<->macula-client traffic share a single canonical wire codec.
Tests — 116 macula_frame tests pass
Round-trip coverage for every frame family (handshake, SWIM, DHT, CALL/RESULT/ERROR, HyParView, Plumtree, PubSub, content). 654 SDK eunit tests pass overall.
[3.5.0] - 2026-04-25
Added — domain-defined record types via macula_record:envelope/4
The SDK now exposes its generic record builder as a public function so domain code (realm-fact emitters, license registries, application-level DHT-stored facts) can mint signed records without needing a per-type constructor in the SDK.
envelope(Type, SignerPubkey, Payload, Opts)— returns an unsigned record map for any tag in0x20-0xFF. The reserved range0x01-0x1Fstays owned by the SDK's typed constructors.- Optional
subject_idopt → 32-byte arbitrary binary. Used bystorage_key/1to derive a per-subject DHT slot (BLAKE3-substituted SHA-256 of <<type, signer_key, subject_id>>) so a single signer can publish many records under distinct slots (e.g., a realm admin signing one record per license). - Wire format adds an optional
u(subject_id) CBOR field alongside the existingt/k/v/c/x/p/senvelope. Records produced under 3.4.0 still verify and decode unchanged; 3.5.0 records withoutsubject_idare wire-identical to 3.4.0.
Drives PLAN_DHT_FIRST.md (macula-realm) — every realm fact becomes a
signed DHT record so stations stay realm-agnostic.
[3.4.0] - 2026-04-25
Added — node_record carries optional geo + reach metadata
Six new optional fields on node_record, settable via the
macula_record:node_record/4 opts map:
hostname— human-readable DNS name (e.g.<<"relay-be-leuven.macula.io">>)endpoint— full reach URL (e.g.<<"quic://relay-be-leuven.macula.io:4433">>)city,country— display locationlat,lng— float or integer coordinates; encoded as CBOR text strings viafloat_to_binary/2(compact, 6 decimals) for cross-implementation determinism
Subscribers — particularly macula-realm's topology dashboard —
read these straight from the record payload via payload/1 +
maps:get({text, <<"lat">>}, ...), eliminating the V1
/topology HTTP polling sidetrack.
The fields are additive: records produced with the 3.3.0 API still verify and decode under 3.4.0 unchanged. Old subscribers that aren't aware of the new fields ignore them harmlessly.
CBOR map keys are single-letter only on the wire spec sections that
explicitly demand it; the node_record envelope already uses
descriptive keys (node_id, station_id, realms, capabilities,
caps_hint, display_name), so the new fields use the same
descriptive style.
[3.3.0] - 2026-04-25
Changed (BREAKING) — record API now spec-compliant
3.2.0 shipped a macula_record module with an ad-hoc record format
(BLAKE3-of-content key, custom signing domain, opaque payload). It
was incompatible with the existing Macula V2 record spec
(hecate_record in hecate-station): different signing domain,
different key derivation, no per-type domain separation.
3.3.0 deletes that 3.2.0 module and replaces it with the
spec-compliant record implementation, vendored from
hecate-station. The SDK is now the canonical home for the record
API; downstream consumers (hecate-station, macula-realm) drop their
copies and depend on macula instead.
3.2.0 should not be used. Anyone who pulled it for the
put_record/find_record API: please skip directly to 3.3.0.
macula_record — Macula V2 records (Part 6 §9)
PKARR-compatible CBOR records with single-letter keys (t, k,
v, c, x, p, s), signed with the domain-separated scheme
"macula-v2-record\0" || canonical_cbor(unsigned) (Part 6 §10.2),
addressed by domain-separated storage keys (Part 3 §3.3).
Typed constructors for all 11 spec record types: node_record/3,4
(type=0x01), realm_directory/3,4 (type=0x03),
realm_stations/2,3 (type=0x04), realm_member_endorsement/2,3
(type=0x05), procedure_advertisement/3,4 (type=0x06),
tombstone/3,4 (type=0x0C), foundation_seed_list/2,3
(type=0x0D), foundation_parameter/3,4 (type=0x0E),
foundation_realm_trust_list/2,3 (type=0x0F),
foundation_t3_attestation/3,4 (type=0x10),
content_announcement/3,4 (type=0x11).
Plus the spec accessors: sign/2, verify/1, refresh/2,
encode/1, decode/1, type/1, key/1, version/1,
created_at/1, expires_at/1, payload/1, signature/1,
storage_key/1.
macula_record_uuid — UUIDv7
Helper for record version fields. Time-ordered 128-bit identifiers,
unique within an Ed25519 signing key's record namespace.
macula_foundation — foundation record helpers
Builders for the four foundation record types (foundation_seed_list,
foundation_parameter, foundation_realm_trust_list,
foundation_t3_attestation) plus verification. Used by the bootstrap
cascade's foundation tier.
macula SDK surface — record RPC API (unchanged shape)
Same procedure namespace + topic shape as 3.2.0, with the spec-compliant record payload:
macula:put_record/2(_dht.put_record)macula:find_record/2(_dht.find_record) — key ismacula_record:storage_key/1outputmacula:find_records_by_type/2(_dht.find_records_by_type)macula:subscribe_records/3/unsubscribe_records/2(_dht.records.<type>.stored)
Backend requirements
The record API depends on the relay backend advertising the
_dht.* procedures and publishing on _dht.records.<type>.stored.
V1 macula-relay does not implement these — they are
hecate-station territory.
[3.2.0] - 2026-04-25 — DO NOT USE
Shipped with a non-spec-compliant macula_record. Replaced by 3.3.0.
Original (now-deleted) entry — for reference
Originally added a record API with BLAKE3-of-content keys and a custom signing domain. The shape conflicted with the existing hecate-station Macula V2 record spec implementation. Replaced wholesale by 3.3.0; see that entry for the canonical API.
[3.1.0] - 2026-04-25
Added — crypto primitives consolidated into the SDK
Two crypto-adjacent modules previously vendored in hecate-station are
now part of the SDK proper. The architectural rule going forward is
crypto primitives belong in the SDK, not in consumers.
macula_identity— Ed25519 keypair generation, sign/verify, public-key extraction, S/Kademlia crypto puzzle. Used by anything that signs records, frames, or session handshakes.macula_record_cbor— Pure-Erlang deterministic CBOR encoder/decoder (RFC 8949 §4.2.1). Distinct frommacula_cbor_nif: this module is the deterministic canonicalization layer used for record signing where byte-for-byte stability is required across implementations. The NIF is for general/perf encoding; this module is for verifiable signing.
Why
hecate-station was the only consumer that needed Ed25519 + record
canonicalization, but the underlying primitives are not station-specific
and would have to be re-implemented for any other consumer (clients
producing signed records, e.g. UCAN-style flows). Centralizing in the
SDK avoids fragmentation.
No breaking change — macula 3.0.x callers see new modules but no
existing API surface moves.
[3.0.0] - 2026-04-23
BREAKING — wire format switched from MessagePack to CBOR (RFC 8949)
The mesh wire protocol now uses CBOR for every frame's payload instead of MessagePack. This is a hard wire-format break: every relay and every SDK consumer must roll forward together. Greenfield migration — no deprecation window.
Why
CBOR was chosen because it composes natively with the rest of the Macula identity + auth stack:
- UCAN tokens — already CBOR-serialized (DAG-CBOR via IPLD)
- DIDs — CBOR-serialized when signed
- Ed25519/X25519 signatures — COSE-CBOR is the canonical wrapper
- Future WebAuthn integration — CBOR-native
With CBOR as the wire format, signature payloads can be canonical-CBOR encoded once and signed directly, removing the msgpack-vs-CBOR double-encoding that previously sat between the protocol and auth layers.
CBOR also brings:
- IETF standardization (RFC 8949) vs msgpack's GitHub-governed spec
- Deterministic encoding rules (RFC 8949 §4.2.1) — required for signed payloads
- IANA-registered tag types for typed data (UUID, datetime, big int)
- Indefinite-length items (streaming-friendly)
Added
macula_cbor_nif— new Erlang module + Rust NIF that pack/unpack Erlang terms to/from CBOR via theciboriumcrate. Loaded automatically; no Erlang fallback (see "No fallback" below).native/macula_cbor_nif/— new Rust crate, ~150 lines, depends onciborium 0.2. Built bypriv/build-nifs.shalongside the existing five NIFs.test/macula_cbor_nif_tests.erl— 20 tests covering primitive roundtrips (int/float/bin/bool/null/list/nested), map roundtrips (including the protocol payload shape), documented lossiness (atoms→binary, tuples→list), error paths (garbage/truncated/empty inputs), and RFC 8949 fixed-prefix self-checks (zero, empty array, empty map, true, false, null).
Removed
msgpackhex package dependency — removed fromrebar.configand from theapplicationslist inmacula.app.src. The pure-Erlang msgpack implementation was the dominant cost in the per-frame serialization path; CBOR via Rust NIF replaces it with byte-identical semantics on the type shapes Macula actually uses.
Migrated call sites (5)
| File | Change |
|---|---|
src/macula_protocol_encoder.erl:43 | msgpack:pack/2 → macula_cbor_nif:pack/1 |
src/macula_protocol_decoder.erl:61 | msgpack:unpack/2 → macula_cbor_nif:unpack/1; error tuple is now {cbor_decode_error, Reason} |
src/macula_mesh_client.erl:777 | args_payload/1 arbitrary-term branch uses macula_cbor_nif:pack/1 |
src/macula_dist_system/macula_dist_relay_protocol.erl:50 | encode uses macula_cbor_nif:pack/1 |
src/macula_dist_system/macula_dist_relay_protocol.erl:57 | decode uses macula_cbor_nif:unpack/1; error tuple is {cbor_decode, Reason} |
Type mapping (Erlang ↔ CBOR)
Atom (true / false) ↔ Bool
Atom (nil / undefined) ↔ Null (decode always returns `nil`)
Atom (other) → Text string (LOSSY — decoder returns binary)
Binary ↔ Byte string
Integer ↔ Integer (uint or negative-int as appropriate)
Float ↔ Float
List ↔ Array
Tuple → Array (LOSSY — decoder returns list)
Map ↔ MapAtoms and tuples lose their type information across the wire — same constraint as the previous msgpack-era protocol. Callers using maps of binary keys (the protocol convention) are unaffected.
No fallback
Unlike the crypto/DID/UCAN/MRI NIFs, macula_cbor_nif has no pure-Erlang
fallback. The protocol layer is in the same critical path as
macula_quic (which also has no Erlang fallback). Failing fast at
NIF-load time is the right behavior; a slow Erlang fallback would
silently halve throughput. If the NIF fails to load, every
pack/unpack call raises {nif_error, nif_not_loaded} — loud,
attributable, recoverable by fixing the build environment.
Migration
For SDK consumers: this is wire-incompatible with v2.x. Daemons and relays running v2.x cannot communicate with v3.x. Roll forward in lockstep.
For any external code that called macula: API with binary args, no
change is needed — the SDK API surface is unchanged. Only the wire
encoding inside the SDK changed.
If you were using msgpack from your own application code that also
imported macula, you will need to add msgpack as your own direct
dependency (it is no longer transitively pulled in by macula).
Pre-3.0 history
Releases prior to 3.0.0 are wire-incompatible (MessagePack era) and have been archived to CHANGELOG_LEGACY.md in the repository. They do not apply to current 3.x consumers.