19 KiB
Fleet Deployment v1 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: Land tines on real robots: handoff receipts in the core (custody chain for addressed data, confidence events for world state), a BLE transport, a persistent store backend, a ROS2 node, and a standalone service.
Architecture: tines-core (no_std) gains the receipt protocol — an Ack frame, Store::release/is_released, bounce direction bits, pending-ack buffers, and a confidence-event queue. Four host crates sit beside the existing tines-sim: tines-bt (BLE transport), tines-store (persistent append-only log), tines-ros2 (self-contained ROS2 node), tines (standalone daemon). The custody chain supersedes the addressed-delivery spec's epidemic forwarding.
Tech Stack: Rust, no_std core (postcard wire codec, heapless buffers), bluer (BlueZ D-Bus) for BLE, rclrs for ROS2, std host crates.
Spec: docs/superpowers/specs/2026-10-18-tines-fleet-deployment-design.md (approved) — the plan argues from the spec; executors read both.
Global Constraints
tines-coreis#![no_std];allocis a cargo feature (default on);--no-default-featuresmust build forthumbv7em-none-eabihf. OnlyMemStoremay requirealloc.- No async, threads, I/O, or panics on untrusted input in core. Malformed frames drop as
Err, never unwrap. - Compile-time constants (exact):
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. New constants in this plan:ACK_IDS_CAP=90,ACK_BUFFER_CAP=256,BCAST_SENT_CAP=256,EVENT_QUEUE_CAP=64. - Every emitted frame fits the transport's
mtu(), including summaries and acks. NodeId = [u8; 16],ObservationId = [u8; 16],Tick = u64.- Addressed invariants:
to.is_some() == lifetime_ms.is_some(); addressed never in bloom/broadcast diff; expired addressed never stored. - Conventional commits (
feat(core): …,feat(bt): …). Gate:sh scripts/ci.sh= fmt → clippy-D warnings→ doc → test → no_std build. Store::release/is_releasedare a breaking change:tines-coreships as 0.3.0 with a CHANGELOG entry.- Specs are design history: do not edit existing specs; the fleet spec supersedes the addressed-delivery spec's epidemic forwarding.
Review Focus
- Forged/replayed ack — an Ack for an id the node never sent to that address must be rejected (bounce direction + broadcast-send record), so a stranger cannot release custody. → Task 3.
- Released duplicate — a duplicate addressed offer whose local copy is released must NOT be acked, or the custodian releases and nobody carries it. → Task 4.
- Ack fits the link — an Ack must be split across frames to fit
mtu(), and an overflowing ack buffer must drop oldest without ever causing a false release (sender keeps carrying). → Task 4. - Reboot — after tines-store reload, released flags survive, ages rebase (no saturation), and
next_seqis monotonic (no content-id collision). → Task 7. - Malformed Ack — a truncated/garbage
Ackframe drops asErr(Malformed)with no panic and no custody change. → Task 2. - World-state ack flood — a large broadcast sync must not evict addressed custody acks (separate buffers), or custody release stalls. → Task 4.
Task 1: Store release contract
Files:
- Modify:
tines-core/src/store.rs - Modify:
tines-core/src/mem_store.rs - Test:
tines-core/src/mem_store.rs(inline#[cfg(test)]) - Modify:
Cargo.toml(workspace root —[workspace.package] version0.2.1→0.3.0) - Modify:
tines-sim/Cargo.toml(tines-coredepversion = "0.2.1"→"0.3.0") - Create:
CHANGELOG.md(workspace root; "0.3.0 —Storegainsrelease/is_released(breaking)")
Interfaces:
-
Produces (required trait methods, no default):
fn release(&mut self, id: &ObservationId)andfn is_released(&self, id: &ObservationId) -> boolonStore. -
MemStoreentry gains areleased: boolfield. -
Step 1: Write the failing test —
release_marks_and_is_released_reports: insert an observation, assert!is_released, callrelease(&id), assertis_released;release_unknown_is_noop:release(&[0u8;16])does not panic andis_releasedstays false. -
Step 2: Run test to verify it fails —
cargo test -p tines-core release_— Expected: FAIL (method not found). -
Step 3: Implement
release/is_releasedinstore.rs(trait, no default) andmem_store.rs(set/read thereleasedflag; unknown id is a no-op;MemStore::for_each_addressed_idskips released ids — an optimization, the exchange predicate is the load-bearing check). -
Step 4: Run test to verify it passes — Expected: PASS; full
cargo test -p tines-corestill green. -
Step 5: Bump version and CHANGELOG — root
Cargo.toml[workspace.package] version = "0.3.0";tines-sim/Cargo.tomltines-coredepversion = "0.3.0"; createCHANGELOG.mdwith the 0.3.0 entry. -
Step 6: Commit —
git add tines-core/src/store.rs tines-core/src/mem_store.rs Cargo.toml tines-sim/Cargo.toml CHANGELOG.md && git commit -m "feat(core): Store::release and is_released (0.3.0)"
Task 2: Ack wire frame
Files:
- Modify:
tines-core/src/wire.rs - Modify:
tines-core/src/consts.rs(addACK_IDS_CAP = 90) - Test:
tines-core/src/wire.rs(inline)
Interfaces:
-
Produces:
Frame::Ack { from: NodeId, ids: heapless::Vec<ObservationId, { consts::ACK_IDS_CAP }> }— added as the last variant; existing variants must not be reordered (postcard tags are positional). -
Frame::sender()must handleAck(returnsfrom). -
Step 1: Write the failing test —
ack_frame_roundtrip: anAckwith 3 ids roundtrips throughencode/decode;decode_garbage_is_error_not_panicstill holds (its length loop already exercises all variants). For variant-order safety,summary_golden_vector: encode a knownSummaryframe, pin the exact bytes as a literal, and assertFrame::decodeof those bytes yields the sameSummary— so reordering the existing variants fails this test. -
Step 2: Run test to verify it fails —
cargo test -p tines-core ack_— Expected: FAIL (variant missing). -
Step 3: Implement
Ack— add the variant last, updatesender(). -
Step 4: Run test to verify it passes — Expected: PASS;
cargo test -p tines-coregreen. -
Step 5: Commit —
git commit -m "feat(core): Ack wire frame (last Frame variant)"
Task 3: Ack receive path — validation, custody release, confidence events
Files:
- Modify:
tines-core/src/exchange.rs - Modify:
tines-core/src/consts.rs(addEVENT_QUEUE_CAP = 64,BCAST_SENT_CAP = 256) - Modify:
tines-core/src/lib.rs(exportConfidenceEvent) - Test:
tines-core/src/exchange.rs(inline)
Interfaces:
-
Produces:
pub struct ConfidenceEvent { pub peer: NodeId, pub id: ObservationId }(peer = who received the obs). -
Bounce records become
(ObservationId, A, BounceKind)whereenum BounceKind { Sent, Received }(private).record_bouncetakes a kind;is_sourcedmatches either kind; a newwas_sent(bounce, id, addr)matchesSentonly. -
Exchange gains
bcast_sent: heapless::Vec<(ObservationId, A), { consts::BCAST_SENT_CAP }>— broadcast sends are recorded into it on the emit path — plusevents: heapless::Vec<ConfidenceEvent, { consts::EVENT_QUEUE_CAP }>(push drop-oldest) andpub(crate) fn drain_events(&mut self) -> heapless::Vec<ConfidenceEvent, { consts::EVENT_QUEUE_CAP }>(hosts reach it viaNode::drain_events, Task 5). -
Step 1: Write the failing test —
ack_validates_only_sent_ids: A sends obs X to peer at addr 1; a forgedAck { from: [2;16], ids: [X] }from addr 2 (which never received X) is ignored —store.is_released(&X)stays false.ack_releases_custody: after A sends X to addr 1, anAckfrom addr 1 listing X releases it (is_released(&X)true,for_each_addressed_id/forwarding skips it).ack_fires_confidence_event: a broadcast obs Y sent to addr 1, anAckfrom addr 1 listing Y,drain_events()yields oneConfidenceEvent { peer: [2;16], id: Y }. -
Step 2: Run test to verify it fails —
cargo test -p tines-core ack_— Expected: FAIL. -
Step 3: Implement — handle
Frame::Ackinon_event: for each id, ifwas_sent(bounce, id, addr)and the local copy is addressed →store.release(&id); else if the id is inbcast_sent→ push aConfidenceEvent. Updaterecord_bouncecall sites (receive path =Received, send path =Sent) and record broadcast sends intobcast_sent. Add the load-bearing!store.is_released(id)check to the custody scan predicate (select_addressed), mirroring the existingtore-check. Reject malformed acks asErr(no panic). -
Step 4: Run test to verify it passes — Expected: PASS.
-
Step 5: Commit —
git commit -m "feat(core): ack validation, custody release, confidence events"
Task 4: Ack send path — buffers, eligibility, emission
Files:
- Modify:
tines-core/src/exchange.rs - Modify:
tines-core/src/consts.rs(addACK_BUFFER_CAP = 256) - Test:
tines-core/src/exchange.rs(inline)
Interfaces:
-
Consumes:
bcast_sentfrom Task 3. -
Exchange gains:
acks: heapless::Vec<(ObservationId, A), { consts::ACK_BUFFER_CAP }>(addressed) andbcast_acks: heapless::Vec<(ObservationId, A), { consts::ACK_BUFFER_CAP }>(broadcast). Overflow drops the oldest entry. -
accept()enqueues(id, addr)into the right buffer (addressed →acks, broadcast →bcast_acks), except: a duplicate addressed offer whose local copyis_releasedis not enqueued. -
next_frameemits a pending Ack after a due summary and before further addressed/broadcast traffic: drain up toACK_IDS_CAPids for one addr into aFrame::Ack, splitting across frames. -
Step 1: Write the failing test —
released_duplicate_is_not_acked: B releases X, then receives X again from A; B emits noAckfor X (drain frames, assert none).ack_fits_mtu: withmtusmall enough for ~5 ids, the emittedAckframes each carry at most that many ids.broadcast_flood_does_not_evict_custody_ack: fillbcast_ackspast capacity, assert an addressed pending ack is still emitted.duplicate_broadcast_is_acked: B receives the same broadcast Y twice, both times an Ack for Y is emitted. -
Step 2: Run test to verify it fails — Expected: FAIL.
-
Step 3: Implement — the buffers,
accepteligibility (released-duplicate skip), and Ack emission with mtu-aware splitting. -
Step 4: Run test to verify it passes — Expected: PASS.
-
Step 5: Commit —
git commit -m "feat(core): ack send path with custody/broadcast buffers"
Task 5: Node event surface
Files:
- Modify:
tines-core/src/node.rs - Test:
tines-core/tests/pair.rs
Interfaces:
-
Consumes:
Exchange::drain_events(Task 3). -
Produces:
pub fn drain_events(&mut self) -> impl Iterator<Item = ConfidenceEvent> + '_onNode, draining the exchange's queue. -
Step 1: Write the failing test —
confidence_event_surfaces_on_node: two nodes on aPairTransport; Aobserves a broadcast, both poll until B stores it; asserta_node.drain_events()yields aConfidenceEventwithpeer == b.id()and the obs id (note: B receives from A, so A's node surfaces the event). If order is ambiguous, assert the set of events contains it. -
Step 2: Run test to verify it fails — Expected: FAIL (
drain_eventsmissing). -
Step 3: Implement — delegate to
self.exchange.drain_events(). -
Step 4: Run test to verify it passes — Expected: PASS.
-
Step 5: Commit —
git commit -m "feat(core): Node::drain_events for confidence events"
Task 6: Simulator coverage
Files:
- Modify:
tines-sim/src/sim.rs(exposeobserve_toalready exists; add event access if needed) - Test:
tines-sim/tests/props.rs,tines-sim/tests/relay.rs
Interfaces:
-
Consumes:
Node::drain_events,Store::release/is_released. -
Step 1: Write the failing tests —
custody_chain_delivers_without_carrier_failure: on a connected A→B→C path, Aobserve_to(C); after enough rounds, C holds the message and every carrier that released is not re-forwarding it;no_carrier_failure_always_held: property — for any connected topology and any addressed message, while unexpired and no node dropped it, at least one node (carrier or recipient) holds it;released_duplicate_not_ackedat the sim level: A and B both hold X, B is custodian, A released; reconnect A↔B, B must not end up released-with-nobody-holding. -
Step 2: Run test to verify it fails —
cargo test -p tines-sim— Expected: FAIL (behavior not yet wired through the sim). -
Step 3: Implement — wire event/custody observation into the sim as needed; add any
Simaccessor the tests require (keep the public API minimal). -
Step 4: Run test to verify it passes — Expected: PASS; full
cargo test --workspace --exclude tines-ros2green. -
Step 5: Commit —
git commit -m "test(sim): custody chain and receipt properties"
Task 7: tines-store (persistent backend)
Files:
- Create:
tines-store/Cargo.toml,tines-store/src/lib.rs,tines-store/src/log.rs - Modify:
Cargo.toml(workspace root: add membertines-store) - Test:
tines-store/tests/persistence.rs
Interfaces:
-
Consumes:
Store,StoreError,TrimPolicy,Observation,Tick,ObservationIdfromtines-core. -
Produces:
pub struct LogStore { … }implementingStore, pluspub fn new(path: &Path, now: Tick, first_seq: u64) -> Result<Self, StoreError>;pub fn next_seq(&self) -> u64;pub fn flush(&mut self) -> Result<(), StoreError>. Rebase on load: fold pre-reboot elapsed (measured against the last persisted clock marker) intoage_ms, setstored_at = now; persistreleasedflags andnext_seqin a metadata record; append-only observation log with trim records. -
Step 1: Write the failing tests —
reopen_preserves_store: insert obs + release one, drop, reopen with a freshnow/first_seq, assertcontains,is_released, andnext_seqcontinuation;ages_rebase_on_load: insert atnow=1000, reopen atnow=5000, assertgetage continues from the folded pre-reboot age (not frozen atage_ms, not saturating);next_seq_persists: afterobserve-style inserts,next_seqafter reopen equals the persisted value. -
Step 2: Run test to verify it fails —
cargo test -p tines-store— Expected: FAIL (crate missing). -
Step 3: Implement — the append-only log, trim records, metadata (released flags, next_seq, last clock marker), and
Storeimpl delegating to an in-memory index rebuilt at load. -
Step 4: Run test to verify it passes — Expected: PASS.
-
Step 5: Commit —
git commit -m "feat(store): persistent append-only LogStore"
Task 8: tines-bt (BLE transport)
Files:
- Create:
tines-bt/Cargo.toml,tines-bt/src/lib.rs,tines-bt/src/adv.rs,tines-bt/src/transport.rs - Modify:
Cargo.toml(workspace root: add membertines-bt) - Test:
tines-bt/tests/adv.rs
Interfaces:
-
Consumes:
Transport,Event,SendError,NodeIdfromtines-core;bluer(D-Bus BlueZ). -
Produces:
pub struct BtTransport { … }implementingTransport<Addr = NodeId>;pub fn advertise(node: NodeId) -> [u8; 31](16-bit service UUID + flags + NodeId in service data); the transport reports real MTU viamtu(), drops frames whosefrom!= the connection's advertisedNodeId, and the smaller-NodeId node acts as BLE central. -
Radio I/O is behind a small internal trait so unit tests exercise
advand the mismatch-drop logic without hardware. -
Step 1: Write the failing tests —
advertising_payload_fits_31_bytesandadvertising_payload_roundtrips_node_id;from_mismatch_is_dropped: a mocked inbound frame whosefrom!= the advertised id yields noEvent::Frame. -
Step 2: Run test to verify it fails —
cargo test -p tines-bt— Expected: FAIL. -
Step 3: Implement — advertising payload assembly,
Addr = NodeId, mismatch-drop, central/peripheral role rule,mtu()(L2CAP CoC when available, else ATT MTU via GATT fallback). -
Step 4: Run test to verify it passes — Expected: PASS.
-
Step 5: Commit —
git commit -m "feat(bt): BLE transport (bluer)"
Task 9: tines-ros2 (ROS2 node)
Files:
- Create:
tines-ros2/Cargo.toml,tines-ros2/src/lib.rs,tines-ros2/src/topics.rs,tines-ros2/src/node.rs - Modify:
Cargo.toml(workspace root: add membertines-ros2);scripts/ci.sh(excludetines-ros2from the default gate); Create:scripts/check-ros2.sh - Test:
tines-ros2/tests/topics.rs
Interfaces:
-
Consumes:
Node,LogStore,BtTransport,ConfidenceEvent;rclrs. -
Produces:
pub fn topic_name(tines_topic: &[u8]) -> String— hex-escapes into/tines/<hex>;pub struct TinesNode { … }— a self-contained ROS2 node owning aNode<LogStore, BtTransport>, publishing each tines topic as a ROS2 topic and/tines/receiptsfor confidence events, subscribing for new observations. Lazy topic creation bounded by the store's live topics and a configurable cap. -
Step 1: Write the failing tests —
topic_name_escapes(arbitrary bytes → valid ROS2 name under/tines/),topic_name_roundtrip(deterministic, injective for MAX_TOPIC inputs). -
Step 2: Run test to verify it fails —
cargo test -p tines-ros2— Expected: FAIL. -
Step 3: Implement — topic escaping, then the node (rclrs): bridge topics ↔ observations, publish receipts, subscribe app observations.
-
Step 4: Run test to verify it passes — Expected: PASS (unit tests only; live-graph tests are
#[ignore]d). -
Step 5: Wire CI —
scripts/ci.shchangescargo test --workspaceand related commands to--exclude tines-ros2;scripts/check-ros2.shbuilds/teststines-ros2when ROS2 is present (documented, not run in CI). -
Step 6: Commit —
git commit -m "feat(ros2): self-contained ROS2 node"
Task 10: tines (standalone service)
Files:
- Create:
tines/Cargo.toml,tines/src/main.rs,tines/src/ipc.rs - Modify:
Cargo.toml(workspace root: add membertines) - Test:
tines/tests/ipc.rs
Interfaces:
-
Consumes:
Node,LogStore,BtTransport; a Unix-domain-socket IPC loop. -
Produces:
tinesbinary: a standalone service owningNode<LogStore, BtTransport>with a Unix socket (small request/response protocol:observe,belief,events,status). One instance per device. -
Step 1: Write the failing tests —
ipc_observe_and_belief_roundtrip: a client sends anobserverequest over the socket, then abeliefrequest returns the stored observation;ipc_events_drains: after a peer ack, aneventsrequest surfaces confidence events. -
Step 2: Run test to verify it fails —
cargo test -p tines— Expected: FAIL. -
Step 3: Implement — the IPC protocol and the
mainwiring (Node + LogStore + BtTransport + poll loop + socket serve). -
Step 4: Run test to verify it passes — Expected: PASS.
-
Step 5: Commit —
git commit -m "feat: standalone tines service with Unix-socket IPC"