8.2 KiB
Fleet fixes (security assessment F1–F5, F17) — 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: Fix the six M3 findings so the fleet milestone does what its spec promises. No new features.
Spec: docs/superpowers/specs/2026-10-19-tines-fleet-fixes.md (draft v1). Findings assessed in THREAT_MODEL.md at 3b1a644. Host hardening (F10–F12) and 1.0 core hardening (F6/F8/F9/F13) are separate specs, out of scope here.
Architecture: F1 is a core behaviour change (ack eligibility). F17 touches both hosts (tines, tines-ros2). F4+F5 rewrite the tines-store log format (v1: header, CRC32C, sync markers, boot counter, durable releases, clock marker, exclusive lock, 0600 perms). F2 gives the tines service a poll loop. F3 rewrites tines-bt (SeqPacket framing, role assignment, NodeId binding, Contact/Lost, multi-peer) and adds core frame_sender.
Global Constraints
- tines-core invariants unchanged:
#![no_std], no I/O, no panic on untrusted input,Addr↔NodeIdmapping stays in the transport. - F1 must keep the fleet sim properties green, plus add the full-store custody property.
- No robots run tines yet → no migration; logs are wiped (1.0 spec D5).
- TDD per AGENTS.md: each finding gets a failing regression test first.
- Gate:
sh scripts/ci.sh(fmt → clippy-D warnings→ doc → test → no_std). tines-ros2 stays excluded from the default gate.
Review Focus
- F1: a store-full (
Err) receive must NOT be acked — addressed or broadcast — or custody is released for a message nobody kept. → Task 1. - F4:
first_seqmust be monotonic across reboots (boot counter), and released flags must survive a kill-without-flush. → Task 3. - F5: a corrupt middle record costs that record, not the rest of the log; only a torn tail truncates. → Task 3.
- F3: inbound connections must bind to an advertised NodeId, and frame boundaries must survive the BLE stream. → Task 5.
Task 1: F1 — ack only what was kept (tines-core)
Files: tines-core/src/exchange.rs; test in tines-core/src/exchange.rs; sim property in tines-sim/tests/props.rs + a capped-store wrapper in tines-sim.
Interfaces: Exchange::accept enqueues a pending ack only when insert returns Ok (new or non-released duplicate). Err(_) (full/backend) → no ack, addressed or broadcast. No bounce record on Err (already true).
- Step 1: failing test — a store returning
Full: noAckemitted; sender's copy stays unreleased. - Step 2: run, see it fail.
- Step 3: implement (gate the ack enqueue on
res.is_ok(); fix theacceptcomment "even an un-stored id"). - Step 4: green.
- Step 5: add the sim property (≥ 1 holder with a small-capacity store returning
Full). - Step 6: commit —
fix(core): ack only observations the store kept.
Task 2: F17 — one monotonic millisecond clock (tines, tines-ros2)
Files: tines/src/ipc.rs (+ main), tines-ros2/src/node.rs (doc).
Interfaces: HostClock (an Instant captured at process start; now() -> Tick in ms). Replaces now_ms() (which returns wall-clock seconds). LogStore::open takes now from this clock (0 at startup, correct by construction). Wall-clock only for observed_at.
- Step 1: failing test — the service's tick advances in ms; an addressed obs with
lifetime_ms = 1000expires in ~1 s, not ~1000 s. - Step 2: run, see it fail.
- Step 3: implement
HostClock, swapnow_ms()→now(), document the clock forTinesNode::poll. - Step 4: green.
- Step 5: commit —
fix(hosts): one monotonic millisecond clock.
Task 3: F4 + F5 — tines-store log format v1
Files: tines-store/src/log.rs, tines-store/src/lib.rs, tines-store/tests/persistence.rs.
Interfaces:
-
F4: persisted boot counter (
openincrements +fsyncs);first_seq() -> u64 = boot_count << 32; remove the insert-countingnext_seqandMeta.next_seq;releaseappendsRelease { id }immediately (infallible; append error logged);flushappendsClock { tick }(age fold on load, saturating); both hosts seedNode::newfromstore.first_seq(). -
F5: log v1 — header
"TINESLOG" ‖ format_version u16 ‖ reserved u16; recordsync u32 ‖ len u32 ‖ crc32c u32 ‖ body; CRC overlen ‖ body; write capMAX_RECORD_BYTES; recovery skips bad records (scan for nextsync, verify CRC) and truncates only a torn tail; ids recomputed from the decoded observation (drop storedid); exclusiveFile::try_lock; file mode0600, parent dir0700; header error names the fix. -
Step 1: failing tests — reopen twice:
first_seqstrictly increases, no id collision; release + kill-without-flush + reopen survives; ages fold the clock marker; corrupt middle record: later records survive; torn tail truncated; oversized record refused at write; secondopenfails while locked; perms0600. -
Step 2: run, see them fail.
-
Step 3: implement F4 then F5 (one format change), TDD each.
-
Step 4: green (
cargo test -p tines-store+ full gate). -
Step 5: commit —
fix(store): log format v1 with durable releases and crash recovery.
Task 4: F2 — the service runs the exchange (tines)
Files: tines/src/main.rs, tines/src/ipc.rs (→ poll thread), tines/src/lib.rs.
Interfaces: Node behind a Mutex, shared by the IPC thread and a new exchange thread. Exchange thread: poll(now) until 0 or a per-iteration cap; sleep poll_interval_ms (default 20); on schedule trim(policy, now) + store.flush(). Lock poisoning → process exit non-zero. The loop is generic over Transport.
- Step 1: failing test — two service instances over an in-memory transport: an observation through one's IPC is a belief through the other's.
- Step 2: run, see it fail.
- Step 3: implement the poll thread + trim/flush schedule + poison-exit.
- Step 4: green.
- Step 5: commit —
fix(service): run the exchange on a poll loop.
Task 5: F3 — tines-bt framing, roles, binding, contact events
Files: tines-bt/src/transport.rs, tines-bt/src/adv.rs, tines-bt/src/lib.rs; tines-core/src/wire.rs (add pub fn frame_sender(bytes) -> Option<NodeId>).
Interfaces:
-
l2cap::SeqPacket(frame = one SDU); MTU clamped toMAX_FRAME; fallbacku16length prefix if SeqPacket fails on target BlueZ (record which). -
Roles: everyone advertises + scans; smaller NodeId dials; larger accepts on a
SeqPacketListenerbound to the tines PSM0x008D; listener runs the whole session. -
Binding: scanner decodes advertised NodeId (checking UUID
0xFE7E), recordsbt address → NodeId; allowlistpeers(empty = any tines advertiser); outbound bound to advertised id; inbound bound viapeer_addr()in the scan map (unknown → close); use the discovered address type (not hard-codedLePublic). -
Addr= bound NodeId;read_loopstamps frames with it;polldrops frames whose decodedfrom≠ boundAddr(peek viaframe_sender). -
Contact/Loston connection up/end; scan+listen continue after a loss. -
Up to
MAX_PEERSconcurrent connections, one per NodeId (replaces the singlepeerfield). -
Radio/adapter errors surfaced (
RadioEventor returned error), not swallowed. -
Step 1: failing tests — frame boundaries preserved back-to-back; mismatched
fromdropped;Contact/Lostemitted; unknown inbound rejected; role decided by NodeId order. -
Step 2: run, see them fail.
-
Step 3: implement (mock-
Radiotestable; SeqPacket path manually two-device, written up intines-bt/README.md). -
Step 4: green.
-
Step 5: commit —
fix(bt): framing, roles, NodeId binding, contact events.
Done means
sh scripts/ci.shgreen, every regression test added first and seen failing.- Fleet sim properties pass, plus the full-store custody property.
- Manual two-robot BLE check documented in
tines-bt/README.md. THREAT_MODEL.mdmarks F1–F5 and F17 fixed with the fixing commit.