Skip to content

Bulletin board

The bulletin board is an optional mesh service for durable, retrievable messages stored on infrastructure nodes. A host keeps many entries per channel (subject to a maximum count); each entry is timestamped and may carry a TTL in minutes (with forever and a maximum representable value).

Wire status: Bulletin opcodes (0x710x75) are Normative Optional — see Compatibility matrix. See Payloads for core payloadType values.

Who may host

RoleBulletin host
GatewaySHOULD
RepeaterMAY
ClientMUST NOT (including temporary relay via Dynamic Repeater Mode)

Rationale: bulletin storage implies flash wear and consistent availability. Battery-powered opportunistic relays would create split caches and premature flash degradation.

Data model (logical)

Each stored entry is an independent record (multiple per channel):

FieldDescription
entryId4-byte local ID (unique per host).
channelHash2-byte LE uint16 — on-wire channelHash identifying the virtual wire (see Channels & security). Hosts MAY cache a 32-bit digest internally for provisioning UI.
authorIdSource nodeId32 (4 bytes LE).
postedAt32-bit UTC epoch seconds (little-endian on the wire). Assigned by the host at accept time unless policy allows author time (implementation MUST document).
ttlMinutes16-bit retention: 0 = no expiry by TTL (retain until moderation or manual delete); 10xFFFF = retain for that many minutes after postedAt, then evict.
messageIdOriginal transport messageId for deduplication.
payloadSame encoding as live traffic (text, sensor snapshot, etc.).

TTL semantics

  • ttlMinutes = 0: Entry does not expire on a time basis; hosts MAY still cap total count or flash usage by policy.
  • ttlMinutes = N (1–65535): Host MUST evict when now ≥ postedAt + N × 60 (seconds), using a clock consistent with postedAt (UTC if available).
  • Maximum: 0xFFFF minutes is the largest expressible finite TTL (~45.5 days). 0 is reserved for forever, not “minimum TTL.”

Ordering and multiplicity

  • Hosts store multiple entries per channelHash (or per logical channel name internally); clients list them newest-first or by entryId as documented per implementation.
  • BULLETIN_QUERY returns up to maxCount rows with postedAt so UIs can sort and show age.

Capacity cap (hosts)

Each bulletin host enforces a finite store — implementations MUST document:

PolicyDescription
nMaxTotalMaximum total bulletin entries on this node (flash/RAM budget).
nMaxPerChannel (optional)Additional cap per channelHash (or per logical channel); if absent, only the total cap applies.

When a new BULLETIN_POST would exceed a cap, the host MUST either reject the post (with a defined error / no ACK) or evict existing entries under a documented policy (e.g. oldest postedAt first, shortest remaining TTL first, or lowest priority if extended). Pinned/moderated entries MAY be exempt. See Embedded storage for an optional lowmesh::nvm::BulletinHostPrefs layout (camelCase members such as maxEntriesTotal).

Operations

Post — BULLETIN_POST (0x71)

FieldSizeDescription
messageId4Deduplication key (LE).
ttlMinutes20 = forever; 10xFFFF = minutes (LE).
bodyLen2Length of body (LE).
bodybodyLenTypically raw UTF-8 bytes (recommended) or a tagged inner format — see below.

Inner format (recommended for text): body is UTF-8 without BOM (see Text encoding). Structured posts MAY prefix with a type byte; implementations SHOULD document.

Host assigns entryId and postedAt, then replies with BULLETIN_ACK.

Ack — BULLETIN_ACK (0x73)

FieldSizeDescription
entryId4Assigned by host (LE).
postedAt4UTC epoch seconds (LE).
messageId4Echo from post (LE).
ttlMinutes2Echo / clamped value (LE).

Notify — BULLETIN_NOTIFY (0x75)

A lightweight broadcast from a bulletin host indicating that new posts are available on a given board. Clients MAY treat this as a wake hint (PHY / MAC beacon policy, product-defined): they compare latestEntryId to a local cache and pull full bodies only when they are behind (via BULLETIN_QUERY). The inner payload carries no message text — only enough data to scope the board and signal freshness.

FieldSizeDescription
channelHash2LE — identifies which channel’s bulletin partition was updated (same 16-bit wire channelHash as the mesh base header domain; hosts map this to stored rows / keys).
latestEntryId4LEentryId of the newest post currently available on that board from this host.

Rules: The inner tail length MUST be exactly 6 bytes (2 + 4). Hosts SHOULD send BULLETIN_NOTIFY only after a successful BULLETIN_POST accept (and optional BULLETIN_ACK to the poster). Delivery MAY be flood, scoped flood (TTL), or local broadcast per deployment policy.

Query — BULLETIN_QUERY (0x72)

FieldSizeDescription
channelHash2Target channelHash (LE uint16, same as mesh header).
sinceEntryId4Return entries newer than this id; 0 = from oldest or implementation default (LE).
maxCount1Max entries (1–255).

Query response — BULLETIN_QUERY_RSP (0x74)

Returned by the bulletin host (unicast or as policy allows). May be fragmented if many/large rows — see Fragmentation; all fragments share the mesh messageId.

FieldSizeDescription
rowCount1Number of rows following (0–255).
repeated rowCount times

Each row:

FieldSizeDescription
entryId4LE
authorId4nodeId32 (LE uint32)
postedAt4UTC epoch seconds (LE)
ttlMinutes2As stored (LE)
bodyLen2LE
bodybodyLenSame encoding as post body

Clients compute expiry as: if ttlMinutes == 0, no TTL expiry; else postedAt + ttlMinutes × 60 (seconds).

Hybrid sync workflow

Stateless bulletin retrieval is normally pull-driven (BULLETIN_QUERY). BULLETIN_NOTIFY adds a push-to-notify layer so battery clients can sleep and wake on a cheap signal before paying the cost of a full query and row bodies:

  1. The host receives and stores a BULLETIN_POST (assigns entryId, persists the row).
  2. The host broadcasts BULLETIN_NOTIFY (0x75) with that board’s channelHash and the new latestEntryId (flood vs. local broadcast vs. TTL is policy).
  3. Listening clients compare latestEntryId (and channelHash) to their local cache for that board.
  4. If the client is missing entries (for example, cached high-water entryId is less than latestEntryId), it issues a normal BULLETIN_QUERY (0x72) to pull metadata and bodies for the gap (or refreshes from sinceEntryId as defined for query).

This keeps the radio beacon small while full payloads still flow only over BULLETIN_QUERY / BULLETIN_QUERY_RSP.

Moderation (optional)

Gateways MAY implement pinning, deletion, or rate limits per authorId. This is policy, not core crypto.

Remote moderation over the mesh (delete entry, change caps, pin) MUST use encrypted admin commands on the admin channelHash with the admin channel key — not the public community key alone. See Mesh admin & operator channel. Holders of normal channel keys MUST NOT be able to impersonate operators for those actions.

Privacy

  • On the default community channel (channelHash == 0x0000), bulletin inner content requires defaultCommunityPsk32 to decrypt; RF listeners without the key see ciphertext only (PHY metadata still visible).
  • On private channels, only holders of the matching AES-256 key can decrypt stored ciphertext.

UX expectations

Handheld clients show bulletin as a slow lane compared to live chat: pull-to-refresh queries the nearest bulletin-capable repeater or gateway (unicast / flood per product policy).

See Payloads for the payloadType registry.

LowMeshOS — always open-source mesh protocol documentation