Appearance
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 (
0x71–0x75) are Normative Optional — see Compatibility matrix. See Payloads for corepayloadTypevalues.
Who may host
| Role | Bulletin host |
|---|---|
| Gateway | SHOULD |
| Repeater | MAY |
| Client | MUST 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):
| Field | Description |
|---|---|
entryId | 4-byte local ID (unique per host). |
channelHash | 2-byte LE uint16 — on-wire channelHash identifying the virtual wire (see Channels & security). Hosts MAY cache a 32-bit digest internally for provisioning UI. |
authorId | Source nodeId32 (4 bytes LE). |
postedAt | 32-bit UTC epoch seconds (little-endian on the wire). Assigned by the host at accept time unless policy allows author time (implementation MUST document). |
ttlMinutes | 16-bit retention: 0 = no expiry by TTL (retain until moderation or manual delete); 1–0xFFFF = retain for that many minutes after postedAt, then evict. |
messageId | Original transport messageId for deduplication. |
payload | Same 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 whennow ≥ postedAt + N × 60(seconds), using a clock consistent withpostedAt(UTC if available).- Maximum:
0xFFFFminutes is the largest expressible finite TTL (~45.5 days).0is 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 byentryIdas documented per implementation. BULLETIN_QUERYreturns up tomaxCountrows withpostedAtso UIs can sort and show age.
Capacity cap (hosts)
Each bulletin host enforces a finite store — implementations MUST document:
| Policy | Description |
|---|---|
nMaxTotal | Maximum 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)
| Field | Size | Description |
|---|---|---|
messageId | 4 | Deduplication key (LE). |
ttlMinutes | 2 | 0 = forever; 1–0xFFFF = minutes (LE). |
bodyLen | 2 | Length of body (LE). |
body | bodyLen | Typically 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)
| Field | Size | Description |
|---|---|---|
entryId | 4 | Assigned by host (LE). |
postedAt | 4 | UTC epoch seconds (LE). |
messageId | 4 | Echo from post (LE). |
ttlMinutes | 2 | Echo / 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.
| Field | Size | Description |
|---|---|---|
channelHash | 2 | LE — 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). |
latestEntryId | 4 | LE — entryId 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)
| Field | Size | Description |
|---|---|---|
channelHash | 2 | Target channelHash (LE uint16, same as mesh header). |
sinceEntryId | 4 | Return entries newer than this id; 0 = from oldest or implementation default (LE). |
maxCount | 1 | Max 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.
| Field | Size | Description |
|---|---|---|
rowCount | 1 | Number of rows following (0–255). |
repeated rowCount times |
Each row:
| Field | Size | Description |
|---|---|---|
entryId | 4 | LE |
authorId | 4 | nodeId32 (LE uint32) |
postedAt | 4 | UTC epoch seconds (LE) |
ttlMinutes | 2 | As stored (LE) |
bodyLen | 2 | LE |
body | bodyLen | Same 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:
- The host receives and stores a
BULLETIN_POST(assignsentryId, persists the row). - The host broadcasts
BULLETIN_NOTIFY(0x75) with that board’schannelHashand the newlatestEntryId(flood vs. local broadcast vs. TTL is policy). - Listening clients compare
latestEntryId(andchannelHash) to their local cache for that board. - If the client is missing entries (for example, cached high-water
entryIdis less thanlatestEntryId), it issues a normalBULLETIN_QUERY(0x72) to pull metadata and bodies for the gap (or refreshes fromsinceEntryIdas 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 requiresdefaultCommunityPsk32to 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.