macula_frame (macula v7.0.0)

View Source

CBOR-encoded wire frames for Macula V2 (Part 6 §3 canonical wire).

A wire frame is a length-prefixed deterministic CBOR map:

    <<Length:32/big, Cbor/binary>>
  

where Cbor is the RFC 8949 §4.2.1 deterministic encoding of a single map. The map carries the common header fields (Part 6 §3) plus type-specific fields and an Ed25519 signature.

PLAN_WIRE_CBOR.md migrated this codec from BERT to CBOR so hecate-station and the macula 3.x SDK share a wire format. Frame schemas (atom-keyed maps in process memory) are unchanged; the codec transparently round-trips atom keys/values to text strings on the wire and reconstitutes them via binary_to_existing_atom on decode.

Phase 1 covers CONNECT / HELLO / GOODBYE. Phase 2 adds SWIM. Phase 3 (Session 3.4) adds the DHT operation frames from Part 6 §7: PING / PONG, FIND_NODE / NODES, FIND_VALUE / VALUE, STORE / STORE_ACK, REPLICATE / REPLICATE_ACK. Phase 4 (Session 4.1) adds the CALL / RESULT / ERROR frames from Part 6 §5 plus the BOLT#4 error taxonomy in hecate_bolt4. PUBLISH frames land later.

Signatures are Ed25519 over "macula-v2-frame\0" ++ canonical_cbor(unsigned) where canonical_cbor is macula_record_cbor:encode/1 (RFC 8949 §4.2.1 deterministic).

Summary

Functions

Is this whole frame sendable? Used by macula_peering:send_frame/2, which is the single seam every producer passes through.

Is this term admissible as a frame payload?

Decode a single length-prefixed frame from the head of a buffer. Returns {ok, Frame, RestBuffer}, {more, BytesNeeded} if the buffer is short, or {error, Reason} if the framing is malformed.

Render a rejection as a sentence, with the remedy where there is one. The operator reading a log at 03:00 is not reading edoc.

Drain all complete frames from a buffer. Returns the list of frames (in order) and the remaining (incomplete) buffer.

Add publisher_sig to a PUBLISH or EVENT frame: the publisher's Ed25519 signature over (topic, realm, publisher, seq, payload). Identity must be the key pair / private key of the pubkey in the frame's publisher field.

Verify a frame's publisher_sig against its publisher field. Returns {ok, Frame} on success, {error, Reason} otherwise (no_publisher_sig when the field is absent — callers that want it MUST treat absence as a verification failure, not as "trusted").

Types

advertise_spec/0

-type advertise_spec() ::
          #{realm := id256(),
            procedure := binary(),
            advertiser := macula_identity:pubkey(),
            options => map()}.

block_spec/0

-type block_spec() :: #{mcid := mcid(), payload := binary()}.

call_error_spec/0

-type call_error_spec() ::
          #{call_id := call_id(),
            code := macula_bolt4:code(),
            reported_by := macula_identity:pubkey(),
            detail => binary() | undefined,
            offending_hop => macula_identity:pubkey() | undefined,
            source_route_partial => binary()}.

call_id/0

-type call_id() :: <<_:128>>.

call_spec/0

-type call_spec() ::
          #{call_id := call_id(),
            procedure := binary(),
            realm := id256(),
            payload := term(),
            deadline_ms := integer(),
            caller := macula_identity:pubkey(),
            source_route => binary(),
            retry_budget => non_neg_integer()}.

cancel_spec/0

-type cancel_spec() :: #{blocks := [mcid()]}.

connect_spec/0

-type connect_spec() ::
          #{node_id := macula_identity:pubkey(),
            station_id := macula_identity:pubkey(),
            realms := [macula_identity:pubkey()],
            capabilities := non_neg_integer(),
            puzzle_evidence := <<_:256>>,
            addresses => [map()],
            site => map() | undefined,
            endorsements => [map()]}.

country/0

-type country() :: <<_:16>>.

delivery_channel/0

-type delivery_channel() :: plumtree | dht | direct.

event_spec/0

-type event_spec() ::
          #{topic := binary(),
            realm := id256(),
            publisher := macula_identity:pubkey(),
            seq := non_neg_integer(),
            payload := term(),
            delivered_via := delivery_channel(),
            publisher_sig => binary()}.

find_node_spec/0

-type find_node_spec() ::
          #{key := id256(), origin := macula_identity:pubkey(), depth := non_neg_integer()}.

find_value_spec/0

-type find_value_spec() :: #{key := id256(), origin := macula_identity:pubkey()}.

frame/0

-type frame() :: map().

frame_type/0

-type frame_type() ::
          connect | hello | goodbye | swim_ping | swim_ack | swim_suspect | swim_confirm | ping | pong |
          find_node | nodes | find_value | value | store | store_ack | replicate | replicate_ack |
          call | result | error | hyparview_join | hyparview_forward_join | hyparview_neighbor |
          hyparview_disconnect | hyparview_shuffle | hyparview_shuffle_reply | plumtree_gossip |
          plumtree_ihave | plumtree_graft | plumtree_prune | publish | subscribe | unsubscribe | event |
          advertise | unadvertise | stream_open | stream_data | stream_end | stream_error |
          stream_reply | want | have | block | manifest_req | manifest_res | cancel.

have_entry/0

-type have_entry() :: #{mcid := mcid(), size := non_neg_integer()}.

have_spec/0

-type have_spec() :: #{blocks := [have_entry()]}.

hello_spec/0

-type hello_spec() ::
          #{node_id := macula_identity:pubkey(),
            station_id := macula_identity:pubkey(),
            realms := [macula_identity:pubkey()],
            capabilities := non_neg_integer(),
            accepted := boolean(),
            negotiated_capabilities := non_neg_integer(),
            addresses => [map()],
            site => map() | undefined,
            refusal_code => non_neg_integer() | undefined}.

hyparview_disconnect_spec/0

-type hyparview_disconnect_spec() :: #{realm := id256()}.

hyparview_forward_join_spec/0

-type hyparview_forward_join_spec() ::
          #{realm := id256(),
            new_member := macula_identity:pubkey(),
            ttl := non_neg_integer(),
            arwl := non_neg_integer(),
            prwl := non_neg_integer()}.

hyparview_join_spec/0

-type hyparview_join_spec() :: #{realm := id256(), new_member := macula_identity:pubkey()}.

hyparview_neighbor_spec/0

-type hyparview_neighbor_spec() :: #{realm := id256(), priority := neighbor_priority()}.

hyparview_shuffle_reply_spec/0

-type hyparview_shuffle_reply_spec() :: #{realm := id256(), peer_sample := [macula_identity:pubkey()]}.

hyparview_shuffle_spec/0

-type hyparview_shuffle_spec() ::
          #{realm := id256(),
            origin := macula_identity:pubkey(),
            ttl := non_neg_integer(),
            peer_sample := [macula_identity:pubkey()]}.

id256/0

-type id256() :: <<_:256>>.

manifest_req_spec/0

-type manifest_req_spec() :: #{mcid := mcid()}.

manifest_res_spec/0

-type manifest_res_spec() :: #{mcid := mcid(), manifest := map() | not_found}.

mcid/0

-type mcid() :: <<_:272>>.

member_state/0

-type member_state() :: alive | suspect | confirmed_failed.

msg_id/0

-type msg_id() :: <<_:128>>.

neighbor_priority/0

-type neighbor_priority() :: high | low.

nodes_spec/0

-type nodes_spec() :: #{key := id256(), nodes := [station_ref()]}.

nonce128/0

-type nonce128() :: <<_:128>>.

ping_spec/0

-type ping_spec() :: #{nonce := nonce128()}.

plumtree_gossip_spec/0

-type plumtree_gossip_spec() ::
          #{realm := id256(), msg_id := msg_id(), round := non_neg_integer(), payload := term()}.

plumtree_graft_spec/0

-type plumtree_graft_spec() :: #{realm := id256(), msg_id := msg_id(), round := non_neg_integer()}.

plumtree_ihave_spec/0

-type plumtree_ihave_spec() :: #{realm := id256(), msg_id := msg_id(), round := non_neg_integer()}.

plumtree_prune_spec/0

-type plumtree_prune_spec() :: #{realm := id256()}.

pong_spec/0

-type pong_spec() :: #{nonce := nonce128()}.

publish_spec/0

-type publish_spec() ::
          #{topic := binary(),
            realm := id256(),
            publisher := macula_identity:pubkey(),
            seq := non_neg_integer(),
            payload := term(),
            published_at_ms := non_neg_integer(),
            ttl_ms => non_neg_integer() | undefined,
            publisher_sig => binary()}.

replicate_ack_spec/0

-type replicate_ack_spec() :: #{key := id256(), accepted := boolean()}.

replicate_spec/0

-type replicate_spec() :: #{record := macula_record:record(), new_custodian := boolean()}.

result_spec/0

-type result_spec() ::
          #{call_id := call_id(),
            payload := term(),
            responded_by := macula_identity:pubkey(),
            source_route_reverse => binary()}.

station_ref/0

-type station_ref() ::
          #{node_id := macula_identity:pubkey(),
            station_id := macula_identity:pubkey(),
            addresses := [map()],
            tier := tier(),
            asn := non_neg_integer() | undefined,
            country := country(),
            last_seen_at := pos_integer()}.

station_ref_spec/0

-type station_ref_spec() ::
          #{node_id := macula_identity:pubkey(),
            station_id := macula_identity:pubkey(),
            addresses => [map()],
            tier := tier(),
            asn => non_neg_integer() | undefined,
            country := country(),
            last_seen_at := pos_integer()}.

store_ack_spec/0

-type store_ack_spec() :: #{key := id256(), stored := boolean(), reason => atom() | undefined}.

store_spec/0

-type store_spec() :: #{record := macula_record:record()}.

stream_data_spec/0

-type stream_data_spec() ::
          #{stream_id := stream_id(),
            seq := non_neg_integer(),
            encoding := stream_encoding(),
            body := term()}.

stream_encoding/0

-type stream_encoding() :: raw | msgpack.

stream_end_spec/0

-type stream_end_spec() :: #{stream_id := stream_id(), role := stream_role()}.

stream_error_spec/0

-type stream_error_spec() :: #{stream_id := stream_id(), code := binary(), message := binary()}.

stream_id/0

-type stream_id() :: <<_:128>>.

stream_mode/0

-type stream_mode() :: server_stream | client_stream | bidi.

stream_open_spec/0

-type stream_open_spec() ::
          #{stream_id := stream_id(),
            procedure := binary(),
            realm := id256(),
            mode := stream_mode(),
            args := term(),
            deadline_ms := integer(),
            caller := macula_identity:pubkey(),
            source_route => binary(),
            retry_budget => non_neg_integer()}.

stream_reply_spec/0

-type stream_reply_spec() ::
          #{stream_id := stream_id(), payload := term(), responded_by := macula_identity:pubkey()}.

stream_role/0

-type stream_role() :: send | both.

subscribe_spec/0

-type subscribe_spec() ::
          #{topic := binary(),
            realm := id256(),
            subscriber := macula_identity:pubkey(),
            filter => term() | undefined,
            options => map()}.

swim_ack_spec/0

-type swim_ack_spec() ::
          #{round := non_neg_integer(),
            responder := macula_identity:pubkey(),
            incarnation := non_neg_integer(),
            piggyback => [swim_update()]}.

swim_ping_spec/0

-type swim_ping_spec() ::
          #{round := non_neg_integer(), incarnation := non_neg_integer(), piggyback => [swim_update()]}.

swim_suspect_spec/0

-type swim_suspect_spec() ::
          #{target := macula_identity:pubkey(),
            target_incarnation := non_neg_integer(),
            suspected_by := macula_identity:pubkey(),
            ttl := non_neg_integer()}.

swim_update/0

-type swim_update() ::
          #{target := macula_identity:pubkey(),
            state := member_state(),
            incarnation := non_neg_integer(),
            observed_at := pos_integer(),
            by := macula_identity:pubkey(),
            signature => <<_:512>>}.

swim_update_spec/0

-type swim_update_spec() ::
          #{target := macula_identity:pubkey(),
            state := member_state(),
            incarnation := non_neg_integer(),
            observed_at := pos_integer(),
            by := macula_identity:pubkey()}.

tier/0

-type tier() :: 0..4.

unadvertise_spec/0

-type unadvertise_spec() ::
          #{realm := id256(), procedure := binary(), advertiser := macula_identity:pubkey()}.

unsubscribe_spec/0

-type unsubscribe_spec() ::
          #{topic := binary(), realm := id256(), subscriber := macula_identity:pubkey()}.

value_spec/0

-type value_spec() :: #{key := id256(), records := [macula_record:record()]}.

want_entry/0

-type want_entry() :: #{mcid := mcid(), priority => want_priority()}.

want_priority/0

-type want_priority() :: 0..255.

want_spec/0

-type want_spec() :: #{blocks := [want_entry()]}.

Functions

advertise(Spec)

-spec advertise(advertise_spec()) -> frame().

block(_)

-spec block(block_spec()) -> frame().

call(Spec)

-spec call(call_spec()) -> frame().

call_error(Spec)

-spec call_error(call_error_spec()) -> frame().

cancel(_)

-spec cancel(cancel_spec()) -> frame().

check_frame(Frame)

-spec check_frame(frame()) -> ok | {error, {unsupported_payload_type, atom(), [term()]}}.

Is this whole frame sendable? Used by macula_peering:send_frame/2, which is the single seam every producer passes through.

record / records are excluded: prepare_records/1 hands those to macula_record:encode/1, a different encoder with its own rules, so judging them by payload rules would reject frames that encode fine.

KNOWN LIMIT, stated rather than papered over. That exclusion means record-bearing frames (STORE, REPLICATE, VALUE) get weaker guarantees than payload-bearing ones: an unsigned record passes here and then fails in macula_record:encode/1, and byte_floor/1 never sees record bytes, so an oversized record is not caught synchronously. Neither can kill the connection — encode_or_drop/2 catches both — but the caller is told ok and the frame is dropped. So "callers get a structured reason" is true for payload-bearing frames and NOT for record-bearing ones. Closing it means teaching this function the record shape and folding record size into the floor.

check_payload(Payload)

-spec check_payload(term()) -> ok | {error, {unsupported_payload_type, atom(), [term()]}}.

Is this term admissible as a frame payload?

Returns ok, or {error, {unsupported_payload_type, Type, Path}} where Path locates the offending value inside the term (map keys and zero-based list indices, outermost first).

WHY THIS EXISTS. macula_peering:send_frame/2 is a cast, so the frame is encoded later, in the shared peering connection process, with no try/catch around it. A term the codec cannot represent therefore does not fail the publisher — it kills the connection, taking every other producer's in-flight traffic with it, while the publisher was told ok. Checking here, in the caller's process before the cast, is what makes that ok mean something.

This function must agree exactly with to_wire/1 followed by macula_record_cbor:encode/1. That is why it lives beside them rather than in a validation module: the two cannot drift apart without the agreement property test in macula_frame_tests going red.

FLOATS ARE CARRIED, as of the float support in macula_record_cbor. They used to be rejected here, and before that to_wire/1 silently rewrote them as six-decimal text. Both were workarounds for the canonical encoder lacking a float clause, which was never a CBOR limitation: RFC 8949 major type 7 is floats. The encoder now emits binary64, so callers no longer scale to integers to get a number across.

connect(Spec)

-spec connect(connect_spec()) -> frame().

decode(Buf)

-spec decode(binary()) -> {ok, frame(), binary()} | {more, pos_integer()} | {error, term()}.

Decode a single length-prefixed frame from the head of a buffer. Returns {ok, Frame, RestBuffer}, {more, BytesNeeded} if the buffer is short, or {error, Reason} if the framing is malformed.

encode(Frame)

-spec encode(frame()) -> binary().

event(Spec)

-spec event(event_spec()) -> frame().

explain(Other)

-spec explain(term()) -> unicode:chardata().

Render a rejection as a sentence, with the remedy where there is one. The operator reading a log at 03:00 is not reading edoc.

find_node(_)

-spec find_node(find_node_spec()) -> frame().

find_value(_)

-spec find_value(find_value_spec()) -> frame().

frame_id(_)

frame_type(_)

goodbye(Reason, Detail)

-spec goodbye(atom(), binary() | undefined) -> frame().

goodbye(Reason, Detail, Caps)

-spec goodbye(atom(), binary() | undefined, non_neg_integer()) -> frame().

have(_)

-spec have(have_spec()) -> frame().

hello(Spec)

-spec hello(hello_spec()) -> frame().

hyparview_disconnect(_)

-spec hyparview_disconnect(hyparview_disconnect_spec()) -> frame().

hyparview_forward_join(_)

-spec hyparview_forward_join(hyparview_forward_join_spec()) -> frame().

hyparview_join(_)

-spec hyparview_join(hyparview_join_spec()) -> frame().

hyparview_neighbor(_)

-spec hyparview_neighbor(hyparview_neighbor_spec()) -> frame().

hyparview_shuffle(_)

-spec hyparview_shuffle(hyparview_shuffle_spec()) -> frame().

hyparview_shuffle_reply(_)

-spec hyparview_shuffle_reply(hyparview_shuffle_reply_spec()) -> frame().

manifest_req(_)

-spec manifest_req(manifest_req_spec()) -> frame().

manifest_res(_)

-spec manifest_res(manifest_res_spec()) -> frame().

nodes(_)

-spec nodes(nodes_spec()) -> frame().

parse_stream(Buf)

-spec parse_stream(binary()) -> {[frame()], binary()}.

Drain all complete frames from a buffer. Returns the list of frames (in order) and the remaining (incomplete) buffer.

ping(_)

-spec ping(ping_spec()) -> frame().

plumtree_gossip(_)

-spec plumtree_gossip(plumtree_gossip_spec()) -> frame().

plumtree_graft(_)

-spec plumtree_graft(plumtree_graft_spec()) -> frame().

plumtree_ihave(_)

-spec plumtree_ihave(plumtree_ihave_spec()) -> frame().

plumtree_prune(_)

-spec plumtree_prune(plumtree_prune_spec()) -> frame().

pong(_)

-spec pong(pong_spec()) -> frame().

publish(Spec)

-spec publish(publish_spec()) -> frame().

replicate(_)

-spec replicate(replicate_spec()) -> frame().

replicate_ack(_)

-spec replicate_ack(replicate_ack_spec()) -> frame().

result(Spec)

-spec result(result_spec()) -> frame().

sent_at_ms(_)

sign(Frame, Identity)

sign_publisher(Frame, Identity)

-spec sign_publisher(frame(), macula_identity:key_pair() | macula_identity:privkey()) -> frame().

Add publisher_sig to a PUBLISH or EVENT frame: the publisher's Ed25519 signature over (topic, realm, publisher, seq, payload). Identity must be the key pair / private key of the pubkey in the frame's publisher field.

sign_swim_update(Update, Identity)

signature(_)

station_ref(Spec)

-spec station_ref(station_ref_spec()) -> station_ref().

store(_)

-spec store(store_spec()) -> frame().

store_ack(Spec)

-spec store_ack(store_ack_spec()) -> frame().

stream_data(Spec)

-spec stream_data(stream_data_spec()) -> frame().

stream_end(Spec)

-spec stream_end(stream_end_spec()) -> frame().

stream_error(Spec)

-spec stream_error(stream_error_spec()) -> frame().

stream_open(Spec)

-spec stream_open(stream_open_spec()) -> frame().

stream_reply(_)

-spec stream_reply(stream_reply_spec()) -> frame().

subscribe(Spec)

-spec subscribe(subscribe_spec()) -> frame().

swim_ack(Spec)

-spec swim_ack(swim_ack_spec()) -> frame().

swim_confirm(Spec)

-spec swim_confirm(swim_suspect_spec()) -> frame().

swim_ping(Spec)

-spec swim_ping(swim_ping_spec()) -> frame().

swim_suspect(Spec)

-spec swim_suspect(swim_suspect_spec()) -> frame().

swim_update(_)

-spec swim_update(swim_update_spec()) -> swim_update().

unadvertise(_)

-spec unadvertise(unadvertise_spec()) -> frame().

unsubscribe(_)

-spec unsubscribe(unsubscribe_spec()) -> frame().

value(_)

-spec value(value_spec()) -> frame().

verify(Frame, Pub)

-spec verify(frame(), macula_identity:pubkey()) -> {ok, frame()} | {error, term()}.

verify_publisher(Frame)

-spec verify_publisher(frame()) -> {ok, frame()} | {error, term()}.

Verify a frame's publisher_sig against its publisher field. Returns {ok, Frame} on success, {error, Reason} otherwise (no_publisher_sig when the field is absent — callers that want it MUST treat absence as a verification failure, not as "trusted").

verify_swim_update(Update)

-spec verify_swim_update(swim_update()) -> {ok, swim_update()} | {error, term()}.

version(_)

want(_)

-spec want(want_spec()) -> frame().