15 KiB
Addressed Delivery Implementation Plan
For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (
- [ ]) syntax for tracking.
Goal: Add addressed store-and-forward to tines — an observation can name a recipient (to), and any node carries it (multi-hop, epidemic, lifetime-bounded) until it reaches that recipient or expires.
Architecture: Extend Observation with to: Option<NodeId> and lifetime_ms: Option<u64>; the content ID now includes to. Broadcast (to = None) keeps the existing bloom-summary gossip unchanged. Addressed (to = Some(C)) messages live in the same store, are excluded from blooms, and are forwarded on contact — delivered to the recipient, epidemic-forwarded to anyone else — and dropped once expired or over the store's max_count.
Tech Stack: Rust (#![no_std] core + std simulator), postcard (wire), Blake2s-256 (identity), heapless, proptest.
Spec: docs/superpowers/specs/2026-10-18-tines-addressed-delivery.md
Global Constraints
tines-coreis#![no_std];allocis feature-gated (default on); onlyMemStoremay requirealloc.--no-default-featuresmust build forthumbv7em-none-eabihf.- No async, threads, I/O, or panics on untrusted wire input.
- Identity =
first16(Blake2s-256(observer ‖ seq_le ‖ topic_len_le(u16) ‖ topic ‖ payload_len_le(u16) ‖ payload ‖ to_marker)), whereto_markeris0x00forNone,0x01 ‖ node_idforSome.lifetime_msis excluded from identity. - Invariant:
lifetime_ms.is_some() == to.is_some(). - Addressed messages (
to = Some(..)) never appear in a bloom summary. - Expired addressed messages are never stored (expiry checked before insertion).
- An addressed message is expired when
current_age > lifetime_ms(age accumulates per hop). - Constants unchanged:
MAX_TOPIC=64,MAX_PAYLOAD=1024,MAX_PEERS=8,SUMMARY_BYTES=512,BLOOM_K=3,SUMMARY_INTERVAL_MS=2000,REASSEMBLY_TIMEOUT_MS=5000,SEND_QUEUE_CAP=256,MAX_FRAME=1500.
Review Focus
- A wire frame whose
to/lifetime_msviolate the invariant (to = Somebutlifetime_ms = None, or the reverse) is dropped, never stored, never panics. (Task 4) - An expired addressed message is rejected on both the
Obsand the reassembled-Fragreceive paths. (Task 4) - A message addressed to a peer whose
NodeIdis not yet known is still held and epidemic-forwarded, never dropped on sight. (Task 5) - A message never bounces back to the peer it was received from (loop prevention). (Task 5)
max_counttrim removes the oldest addressed messages too, not only broadcast. (Task 2)
Task 1: Observation gains to/lifetime_ms; identity includes to
Files:
- Modify:
tines-core/src/observation.rs(fields,id(),aged()) - Modify (add
to: None, lifetime_ms: Noneto every literal):tines-core/src/node.rs(observe), and test helpers inobservation.rs,mem_store.rs,belief.rs,exchange.rs,frag.rs - Test:
tines-core/src/observation.rs
Interfaces:
-
Produces:
Observation { observer: NodeId, seq: u64, age_ms: u64, observed_at: Option<u64>, topic: Topic, payload: Payload, to: Option<NodeId>, lifetime_ms: Option<u64> }. -
id(): appendto_markerto the hash — one byte0x00ifto.is_none(), else0x01followed by the 16NodeIdbytes. -
aged(elapsed_ms): clonestoandlifetime_msunchanged, advancesage_ms(saturating) as before. -
Step 1: Write the failing tests
Add to observation.rs's tests module (extend the obs helper with to/lifetime_ms, both defaulting to None):
-
to_is_part_of_identity— sameobs(7, 0, None)withto = Nonevsto = Some([2; 16]):assert_ne!the ids. -
lifetime_is_not_part_of_identity— same payload, bothto = Some([2; 16]),lifetime_ms = 100vs200:assert_eq!the ids. -
broadcast_marker_distinct_from_zero_id—to = Nonevsto = Some([0; 16]):assert_ne!the ids. -
aged_preserves_addressing—obswithto = Some([2; 16]),lifetime_ms = Some(500);.aged(10)hasage_ms+10 and unchangedto/lifetime_ms. -
Update
id_known_vector(broadcast,to = None) to expect exactly:[0x4a, 0x33, 0x09, 0x92, 0xf1, 0x61, 0xbe, 0xc9, 0x19, 0x7b, 0x6e, 0x96, 0x11, 0x6f, 0xa4, 0xa2]. -
Step 2: Run tests to verify they fail
Run: cargo test -p tines-core observation — expected: compile errors (to, lifetime_ms unknown on Observation) plus the changed known-vector.
-
Step 3: Implement the two fields on
Observation, theto_markerinid(),aged()'s field clones, and addto: None, lifetime_ms: Noneto everyObservation { .. }literal across the crate and its test helpers. -
Step 4: Run tests to verify they pass
Run: cargo test --workspace — expected: all pass (the known-vector now matches the new hash).
- Step 5: Commit
git add tines-core/src tines-core/tests
git commit -m "feat(core): Observation gains to/lifetime_ms; identity includes to"
Task 2: Store::for_each_broadcast_id
Files:
- Modify:
tines-core/src/store.rs(trait method),tines-core/src/mem_store.rs(impl) - Test:
tines-core/src/mem_store.rs
Interfaces:
-
Produces:
fn for_each_broadcast_id(&self, f: &mut dyn FnMut(&ObservationId))onStore— visits the ids of observations withto == None, in no particular order. -
Step 1: Write the failing test
for_each_broadcast_id_skips_addressed — insert one broadcast obs (to = None) and one addressed obs (to = Some([2; 16])); collect for_each_broadcast_id ids; assert_eq! the set to only the broadcast id. (Review Focus 5 lives here too: trim_max_count_drops_oldest_addressed — insert 10 addressed obs and one broadcast, trim max_count = Some(4), assert the broadcast obs survives and only 4 remain.)
- Step 2: Run tests to verify they fail
Run: cargo test -p tines-core mem_store — expected: no method named for_each_broadcast_id.
-
Step 3: Implement the trait method and the
MemStoreimpl (iterate theBTreeMapvalues, skipheld.obs.to.is_some()). -
Step 4: Run tests to verify they pass
Run: cargo test -p tines-core mem_store — expected: pass.
- Step 5: Commit
git add tines-core/src/store.rs tines-core/src/mem_store.rs
git commit -m "feat(core): Store::for_each_broadcast_id"
Task 3: Summaries carry only broadcast observations
Files:
- Modify:
tines-core/src/exchange.rs - Test:
tines-core/src/exchange.rs
Interfaces:
-
Consumes:
Store::for_each_broadcast_id(Task 2). -
Step 1: Write the failing test
addressed_obs_not_in_summary — a store holding one broadcast obs b and one addressed obs a (to = Some([2; 16])); Contact(1), then next_frame returns a Summary; decode it and assert Bloom::from_parts(salt, &bits) has might_contain(b.id()) == true and might_contain(a.id()) == false.
- Step 2: Run tests to verify they fail
Run: cargo test -p tines-core exchange::tests::addressed_obs_not_in_summary — expected: the addressed id is still in the bloom (assert fails).
-
Step 3: Implement — in
next_frame's summary block, replacestore.for_each_id(..)withstore.for_each_broadcast_id(..). -
Step 4: Run tests to verify they pass
Run: cargo test -p tines-core exchange — expected: pass.
- Step 5: Commit
git add tines-core/src/exchange.rs
git commit -m "feat(core): summaries carry only broadcast observations"
Task 4: Reject expired or malformed addressed messages
Files:
- Modify:
tines-core/src/exchange.rs(on_frame, bothObsandFragarms) - Test:
tines-core/src/exchange.rs
Interfaces:
-
Consumes:
Observationwithto/lifetime_ms. -
Produces: a receive-side guard — an addressed observation is inserted only if it is not expired and its
to/lifetime_mssatisfy the invariant. -
Step 1: Write the failing tests
-
expired_addressed_obs_is_rejected— anObsframe withto = Some(ME),lifetime_ms = Some(100),age_ms = 200:on_eventreturnsNoneandstore.len() == 0. -
expired_addressed_frag_is_rejected— a fragmented observation with the same expired addressing (build viacrate::frag::fragmentafter settingage_ms = 200,lifetime_ms = Some(100)); feed the frags;store.len() == 0. (Review Focus 2) -
addressing_invariant_violation_is_dropped—to = Some(ME),lifetime_ms = None:on_eventreturnsNone,store.len() == 0, no panic. (Review Focus 1) -
Step 2: Run tests to verify they fail
Run: cargo test -p tines-core exchange — expected: the expired/malformed messages are still stored (asserts fail).
-
Step 3: Implement — a private helper, e.g.
fn reject(obs: &Observation) -> bool { obs.to.is_some() && (obs.lifetime_ms.is_none() || obs.age_ms > obs.lifetime_ms.unwrap()) }(the invariant check foldslifetime_ms = Noneinto "reject"), called in both receive arms beforestore.insert. -
Step 4: Run tests to verify they pass
Run: cargo test -p tines-core exchange — expected: pass.
- Step 5: Commit
git add tines-core/src/exchange.rs
git commit -m "feat(core): reject expired or malformed addressed messages"
Task 5: Addressed store-and-forward in the exchange
Files:
- Modify:
tines-core/src/exchange.rs(forwarding + bounce state) - Test:
tines-core/src/exchange.rs
Interfaces:
-
Consumes:
Observationwithto/lifetime_ms,Peer::id. -
Produces: addressed forwarding in
next_frame— when a peer'sidis known, emit (one frame per poll, round-robin over peers and over the store's non-expired addressed observations): delivery whenobs.to == Some(peer.id), epidemic otherwise; skip a message's recorded source peer (bounce suppression); addressed forwarding must not starve the broadcast diff. -
Step 1: Write the failing tests
-
addressed_obs_delivered_to_recipient— store hasto = Some([2; 16]);Contact(1), then aFramefrom addr 1 withfrom = [2; 16](sopeer.id = Some([2; 16])); drainnext_frame(skip the summary) and assert anObswithobs.to == Some([2; 16])is emitted to addr 1. -
addressed_obs_epidemic_forwards_to_non_recipient— same setup butto = Some([3; 16]), peer id[2; 16]; assert anObswithto = Some([3; 16])is emitted to addr 1 (Review Focus 3: the unknown recipient never drops the message). -
addressed_obs_does_not_bounce_to_source— store is empty;on_event(Frame(addr 1, Obs { from: [2; 16], obs: to = Some([3; 16]) })); thennext_frameemits noObsaddressed to[3; 16]back to addr 1 (only a summary). (Review Focus 4) -
Step 2: Run tests to verify they fail
Run: cargo test -p tines-core exchange — expected: no addressed frames are emitted (asserts fail).
-
Step 3: Implement — in
next_frame, after the summary priority, add addressed forwarding: for the round-robin peer, enumerate the store's non-expired addressed observations and emit one (delivery before epidemic). Record the source addr of each inbound addressed message (e.g. a boundedheapless::Vec<(ObservationId, A), SEND_QUEUE_CAP>onExchange) and skip re-forwarding an id to its recorded source. Interleave with the existing broadcastemit_oneso broadcast work still runs. -
Step 4: Run tests to verify they pass
Run: cargo test -p tines-core exchange — expected: pass.
- Step 5: Commit
git add tines-core/src/exchange.rs
git commit -m "feat(core): addressed store-and-forward in the exchange"
Task 6: Node::observe_to and the app-facing view
Files:
- Modify:
tines-core/src/node.rs - Test:
tines-core/tests/pair.rs
Interfaces:
-
Produces:
pub fn observe_to(&mut self, to: NodeId, lifetime_ms: u64, topic: &[u8], payload: &[u8], observed_at: Option<u64>, now: Tick) -> Result<ObservationId, ObserveError>— creates an observation withto = Some(to),lifetime_ms = Some(lifetime_ms),age_ms = 0, incrementednext_seq. -
Node::observeremains broadcast-only (to = None,lifetime_ms = None). -
Node::beliefskipsto = Some(other != me)observations (app view = broadcast + to-me). -
Step 1: Write the failing tests (in
pair.rs) -
observe_to_creates_addressed_message—node.observe_to([2; 16], 5000, b"task", b"go", None, 0)returns an id; the stored observation hasto == Some([2; 16])andlifetime_ms == Some(5000). -
observe_to_rejects_oversized_topic— 65-byte topic →Err(ObserveError::TooLarge). -
belief_excludes_addressed_to_others— store a broadcast obs and an addressed-to-other obs under the same topic prefix;belief(prefix, &LatestWins, now)returns the broadcast obs's payload (not the addressed one). -
Step 2: Run tests to verify they fail
Run: cargo test -p tines-core --test pair — expected: no method named observe_to.
-
Step 3: Implement
observe_to, and add theto = Some(other != me)guard toNode::belief's closure. -
Step 4: Run tests to verify they pass
Run: cargo test -p tines-core --test pair — expected: pass.
- Step 5: Commit
git add tines-core/src/node.rs tines-core/tests/pair.rs
git commit -m "feat(core): Node::observe_to and belief excludes addressed-to-others"
Task 7: Simulator relay scenario, property tests, convergence scoping
Files:
- Modify:
tines-sim/src/sim.rs(addSim::observe_to),tines-sim/tests/props.rs(scope + new properties) - Create:
tines-sim/tests/relay.rs
Interfaces:
-
Consumes:
Node::observe_to(Task 6),SimWorld/SimTransport. -
Produces:
pub fn observe_to(&mut self, node: usize, to: NodeId, lifetime_ms: u64, topic: &[u8], payload: &[u8]) -> ObservationIdonSim. -
Step 1: Write the failing tests
relay.rs — relayed_task_reaches_recipient: three nodes, contacts (0,1) then (1,2); node 0 observe_to([2; 16], 60_000, b"task", b"investigate"); run_until(trace.end()); assert sim.nodes[2].store().contains(&id). Second test: carrier_drops_expired — a 10 ms lifetime with the same relay; after running well past expiry, assert node 2's store does not contain the id.
props.rs — add addressed_reaches_recipient_along_connected_path (a 2..=6-node connected trace; one node tasks the max-id node; assert that node's store eventually contains the id). Do not assert converged for addressed scenarios (addressed messages diverge by design); the existing connected_components_converge and store_is_independent_of_contact_order keep using broadcast-only observe and are unchanged.
- Step 2: Run tests to verify they fail
Run: cargo test -p tines-sim — expected: no method named observe_to on Sim.
-
Step 3: Implement
Sim::observe_to, and the new tests' setup. -
Step 4: Run tests to verify they pass
Run: cargo test --workspace — expected: all pass (including the pre-existing 85+ tests).
- Step 5: Commit
git add tines-sim
git commit -m "test(sim): addressed relay scenario and property tests"
Final Gate
After Task 7, run sh scripts/ci.sh (fmt, clippy -D warnings, doc, test, no_std) — expected: exit 0.