From 6c7b1ed492185d4e108c4fe575599688799f433a Mon Sep 17 00:00:00 2001 From: "K. Isom" Date: Sun, 18 Oct 2026 01:14:57 -0700 Subject: [PATCH] docs: add addressed-delivery design spec (draft) --- .../2026-10-18-tines-addressed-delivery.md | 176 ++++++++++++++++++ 1 file changed, 176 insertions(+) create mode 100644 docs/superpowers/specs/2026-10-18-tines-addressed-delivery.md diff --git a/docs/superpowers/specs/2026-10-18-tines-addressed-delivery.md b/docs/superpowers/specs/2026-10-18-tines-addressed-delivery.md new file mode 100644 index 0000000..588b4e5 --- /dev/null +++ b/docs/superpowers/specs/2026-10-18-tines-addressed-delivery.md @@ -0,0 +1,176 @@ +# tines — Addressed Delivery (store-and-forward) + +Date: 2026-10-18 +Status: draft +Extends: `docs/superpowers/specs/2026-10-16-tines-design.md` + +## Intent + +Robots are mostly offline. Today tines gossips observations to every node it +meets — every fact spreads to everyone, and every node is a sink for its own +world-picture. That covers shared *facts* but not *directives*: there is no way +for one robot to address a message to a specific recipient, or to have an +intermediary carry it there. + +The motivating scenario: a survey drone (A) detects an object of interest that +needs the instrument robot C to investigate. A and C may never meet directly. +When A meets B, A hands B the tasking addressed to C; B carries it until it +meets C (or another node that can keep it moving); C eventually receives it and +recognizes it as already seen. A keeps its own copy and, if it ever meets C, +delivers the details directly. + +A second scenario: a robot that needs retrieval or repair can emit a message +*about itself* — either broadcast (`to = none`) or addressed to a known entity +(its "control"/home base). + +## Goals + +1. A node can address a message to a specific recipient (`to = NodeId`). +2. Multi-hop, epidemic store-and-forward: any node that isn't the recipient can + carry the message and forward it to any peer, until it reaches the recipient + or expires. +3. Best-effort: no delivery acknowledgment; the originator keeps its own copy. +4. Lifetime-bounded: every addressed message carries a lifetime; carriers drop + it once expired. +5. Dedup by content ID: the recipient (and every carrier) recognizes an + already-seen message and ignores duplicates. +6. Local-drop cancellation: a node that is done with a message simply stops + relaying it; nothing propagates. + +## Non-goals / anti-goals + +- No delivery receipts or custody-transfer acknowledgments. A never learns + whether C got it. +- No hop-count limit. Lifetime is the only bound for now; a TTL/hop bound can be + added later. +- No routing intelligence. Forwarding is pure epidemic — no notion of "closer to + the destination." +- No first-class "about" field. The subject stays the `topic` (a naming + convention the application owns). +- No change to the existing broadcast gossip path. +- The simulation GUI (mark a node ready/not, visually interact with memory) is a + separate milestone (M3), out of scope here. + +## Constraints + +- `tines-core` remains `#![no_std]`; `alloc` is feature-gated (default on) and + only `MemStore` may require it. Must build for `thumbv7em-none-eabihf` with + `--no-default-features`. +- No async, threads, I/O, or panics on untrusted wire input. +- The content-ID hash changes: `to` becomes part of identity (a breaking change + to the pinned vector from the M1 spec). +- Addressed messages never appear in a bloom summary (which would leak their + existence and cause them to be gossiped). +- `lifetime_ms` is excluded from the content ID, like `age_ms`/`observed_at`. + +## Data model + +`Observation` gains two fields: + +- `to: Option` — recipient. `None` = broadcast (today's gossip); + `Some(C)` = addressed to C. +- `lifetime_ms: Option` — validity window for addressed messages; + invariant `lifetime_ms.is_some() == to.is_some()`. Set at creation, carried + unchanged, excluded from identity. + +`observer` remains "from whom" (the originator). "about whom/what" remains the +`topic`. + +The content ID becomes +`first-16-bytes(Blake2s-256(observer || seq_le || topic_len_le || topic || +payload_len_le || payload || to))`, where `to = None` hashes as a distinct fixed +marker. Consequence: the same payload addressed to two different recipients is +two messages (distinct IDs), and a relay can hold both. + +## Delivery model + +Two disjoint paths. + +**Broadcast (`to = None`)** — unchanged. Bloom-summary/diff gossip; every node +absorbs what it hears. + +**Addressed (`to = Some(C)`)** — a separate, targeted path: + +- A carrier holds the message in a bounded custody buffer (not the main store, + not the bloom). +- On contact with a peer P: if P is C, deliver it (C absorbs into its store, + deduped by ID); otherwise forward it — P becomes another carrier (multi-hop + epidemic). +- Loop prevention: content-ID dedup makes re-delivery a no-op; a node does not + re-forward a message to the peer it just received it from (immediate-bounce + suppression). + +**Invariant (non-negotiable):** only broadcast observations (`to = None`) are +summarized in a bloom. Addressed messages — to me or to others — are never +summarized, so their existence never leaks to unrelated nodes via gossip. + +## Lifetime and expiry + +An addressed message is valid for `lifetime_ms` from creation. A carrier +computes its current age with the existing machinery +(`current_age = age_ms + (now - stored_at)`) and drops it once +`current_age > lifetime_ms`. Expiry is local and silent. Age already accumulates +per hop (each relay re-encodes the age-adjusted observation), so a lifetime +measured from observation is consistent across every carrier. + +## Cancellation + +A node that is done with a message — the recipient, or a carrier that no longer +cares — simply stops relaying it. No cancellation signal propagates through the +network. + +## Decisions vs. my calls + +**Kyle decided:** `to` is part of identity (content ID); multi-hop epidemic +forwarding; lifetime (per-message, time-based) rather than hop-count; best-effort +with no ack; local-drop cancellation; "about" stays the topic; `observer` is +"from whom." + +**My calls** (the builder-check surfaced five open questions; all resolved below +without needing further input — none changed a Kyle decision): + +- **Custody is a filtered view over the store, not a separate core buffer.** A + `MAX_CUSTODY`-sized heapless buffer of full messages would need ~64 × 1 KB ≈ + 72 KB of core static RAM, breaking the no-alloc MCU constraint. Instead, + addressed messages live in the (backend-owned, alloc-gated) store alongside + broadcast facts; the "custody set" is just "stored observations with + `to = Some(other)` not yet expired." Storage cost is the backend's, exactly as + `Store`/`MemStore` already work. +- **Originator is a carrier.** The originator's own addressed message lives in + that same view and is forwarded like any carrier's copy; it never enters the + broadcast set and never feeds the bloom. +- **Delivery rides the custody path.** A carrier forwards on contact; when the + peer is the recipient, the message is absorbed into its store (deduped by ID, + excluded from the bloom). No separate "direct send" path. +- **Expired-never-stored is a correctness rule.** No node — carrier or recipient + — ever stores an addressed message whose lifetime has expired; expiry is + checked before insertion. +- **The app-facing view is broadcast + to-me.** `for_each_topic`/belief see only + `to = None` and `to = Some(me)` observations; `to = Some(other)` messages are + invisible to queries and exist only to be forwarded. +- **Convergence is broadcast-scoped.** The simulator's `converged`/ + `connected_components_converge` properties apply to broadcast observations + only; addressed delivery gets its own properties (recipient receives a live + message along a connected path; non-recipients dedup; expired never stored). +- **Bounce suppression is per-contact:** a node does not re-forward a message to + the peer it just received it from within the same contact (content-ID dedup + covers correctness otherwise). +- **Scheduling:** under the ≤1-frame-per-poll budget, the exchange interleaves + broadcast (summary/diff) and custody (addressed forward); custody must not + starve broadcast. Exact priority is left to the plan. +- **"control"/"home base"** are application-level names resolved to a `NodeId` + before the message is created; the core sees only `NodeId`s. +- **`lifetime_ms` semantics:** measured from observation, excluded from identity, + `Option` with the `Some`-iff-addressed invariant. + +## What's left to the builder + +- Exact wire-format changes (`to`, `lifetime_ms`) and the new content-ID hash + layout + an updated known-vector test. +- The store/custody-buffer layout and the summary filter that excludes addressed + messages. +- The exchange changes: targeted + epidemic forwarding on contact, bounce + suppression, expiry. +- The `Node` facade API for addressing a message with a recipient and lifetime. +- A simulator scenario reproducing the relay chain (A → B → C, C dedups), plus + property tests for expiry and epidemic spread.