Files
tines/docs/superpowers/plans/2026-10-19-tines-fleet-fixes.md

8.2 KiB
Raw Permalink Blame History

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 ↔ NodeId mapping 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

  1. F1: a store-full (Err) receive must NOT be acked — addressed or broadcast — or custody is released for a message nobody kept. → Task 1.
  2. F4: first_seq must be monotonic across reboots (boot counter), and released flags must survive a kill-without-flush. → Task 3.
  3. F5: a corrupt middle record costs that record, not the rest of the log; only a torn tail truncates. → Task 3.
  4. 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: no Ack emitted; sender's copy stays unreleased.
  • Step 2: run, see it fail.
  • Step 3: implement (gate the ack enqueue on res.is_ok(); fix the accept comment "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 = 1000 expires in ~1 s, not ~1000 s.
  • Step 2: run, see it fail.
  • Step 3: implement HostClock, swap now_ms() → now(), document the clock for TinesNode::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 (open increments + fsyncs); first_seq() -> u64 = boot_count << 32; remove the insert-counting next_seq and Meta.next_seq; release appends Release { id } immediately (infallible; append error logged); flush appends Clock { tick } (age fold on load, saturating); both hosts seed Node::new from store.first_seq().

  • F5: log v1 — header "TINESLOG" ‖ format_version u16 ‖ reserved u16; record sync u32 ‖ len u32 ‖ crc32c u32 ‖ body; CRC over len ‖ body; write cap MAX_RECORD_BYTES; recovery skips bad records (scan for next sync, verify CRC) and truncates only a torn tail; ids recomputed from the decoded observation (drop stored id); exclusive File::try_lock; file mode 0600, parent dir 0700; header error names the fix.

  • Step 1: failing tests — reopen twice: first_seq strictly 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; second open fails while locked; perms 0600.

  • 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 to MAX_FRAME; fallback u16 length prefix if SeqPacket fails on target BlueZ (record which).

  • Roles: everyone advertises + scans; smaller NodeId dials; larger accepts on a SeqPacketListener bound to the tines PSM 0x008D; listener runs the whole session.

  • Binding: scanner decodes advertised NodeId (checking UUID 0xFE7E), records bt address → NodeId; allowlist peers (empty = any tines advertiser); outbound bound to advertised id; inbound bound via peer_addr() in the scan map (unknown → close); use the discovered address type (not hard-coded LePublic).

  • Addr = bound NodeId; read_loop stamps frames with it; poll drops frames whose decoded from ≠ bound Addr (peek via frame_sender).

  • Contact/Lost on connection up/end; scan+listen continue after a loss.

  • Up to MAX_PEERS concurrent connections, one per NodeId (replaces the single peer field).

  • Radio/adapter errors surfaced (RadioEvent or returned error), not swallowed.

  • Step 1: failing tests — frame boundaries preserved back-to-back; mismatched from dropped; Contact/Lost emitted; unknown inbound rejected; role decided by NodeId order.

  • Step 2: run, see them fail.

  • Step 3: implement (mock-Radio testable; SeqPacket path manually two-device, written up in tines-bt/README.md).

  • Step 4: green.

  • Step 5: commit — fix(bt): framing, roles, NodeId binding, contact events.

Done means

  • sh scripts/ci.sh green, 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.md marks F1–F5 and F17 fixed with the fixing commit.