Files
tines/docs/superpowers/plans/2026-10-20-tines-core-1-0.md
T

35 KiB
Raw Blame History

tines-core 1.0 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: Ship tines-core 1.0.0: a versioned wire format, a frozen minimal public API, a settled Store trait, a reserved signature slot, and evidence (fuzzing, pinned vectors, MSRV, semver-checks) that the untrusted-input and compatibility promises hold.

Architecture: 1.0 adds nothing to the exchange's behaviour; it removes, hides, pins, documents and reserves. Two wire-format changes (a version byte, then the Observation reshape) plus a Store trait extension and four core-hardening fixes, each landed as an independently green, committed task on top of master (aedb802).

Tech Stack: Rust 2021, #![no_std] + optional alloc, postcard, heapless, blake2, serde, proptest, cargo-fuzz (nightly), cargo-msrv, cargo-semver-checks.

Spec: docs/superpowers/specs/2026-10-18-tines-1-0-design.md (approved; decisions F and G added 2026-10-20, G amended after review) — the plan argues from the spec, so the spec travels with it; executors read both. Review: HANDOFF.md.

Global Constraints

Every task's requirements implicitly include this section.

  • tines-core stays #![no_std]; alloc an optional default-on feature; --no-default-features builds for thumbv7em-none-eabihf. No async/threads/I/O/panics on untrusted input; wire frames are untrusted, malformed bytes become Err/a counted drop, never a panic.
  • Addr is never mapped to NodeId by core.
  • Identity hash never changes: id() reads dest.map(|d| d.to) and is byte-for-byte the 0.x hash; the pinned vector (below) carries over unchanged. sig is excluded from the id.
  • Exact values: 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, ACK_IDS_CAP=90, EVENT_QUEUE_CAP=64, BCAST_SENT_CAP=256, ACK_BUFFER_CAP=256, MAX_SIG=64, MAX_FRAGMENTS=64 (pub(crate)), FRAG_OVERHEAD (currently 64, becomes 65 in Task 1, private in Task 5), SUMMARY_OVERHEAD (private, currently 24, becomes 25 in Task 1).
  • Decisions already made (do not reopen): D1 reserve signature slot; D2 Dest replaces to+lifetime_ms; D3 drop-and-report; D4 only tines-core goes 1.0; D5 no store migration (old logs refused, wiped); Q1 MAX_SIG=64; Q2 coverage = whole observation + version+algo; Q3 malformed reported like version mismatch; R1 drop reports carry transport Addr; R2 drops counted by (Addr, reason), not queued; R3 MAX_FRAME=1500 stays; R4 observed_at = observer's wall-clock ms since Unix epoch; F Store::flush + Node::flush(now) replaces store_mut; G age across downtime via a wall-clock marker, as amended: a host opens the store only once it has a trusted time source, LogStore::open(path, now, wall_now: u64) takes a plain u64, and the marker's wall time is derived (wall_at_open + (now − tick_at_open)).
  • tines-ros2 is not in the default gate (its ros2 feature is off); verify any change touching tines-ros2 with docker build -f Dockerfile.ros2 -t tines-ros2-check . (cached image exists).
  • Gate: run sh scripts/ci.sh verbatim and require it green at every commit — it runs cargo fmt --all -- --check, then clippy/doc/test for the whole workspace, then the no-alloc clippy/doc/test matrix for tines-core (sh scripts/check-no-std.sh). Do not substitute the individual steps for the script.
  • House rule: primary branch is master; land by rebase + fast-forward. Commit messages use the conventional prefix shown in each task; license Apache-2.0, copyright "K. Isom".
  • Execution: local ornith-1.5-35b-a3b subagents, reviews run sequentially (single-GPU llama-server). When a task touches tines-ros2, its final step is the Docker build, not a plain cargo check.

Review Focus

The six failure modes the spec implies but no single task's tests exercise, most likely to bite first:

  1. A frame with the wrong version byte (un-updated or foreign peer) must be dropped and reported as VersionMismatch { version }, never silently swallowed and never folded into Malformed. An empty frame is Malformed (there is no version to mismatch). → pinned in Task 2.
  2. A frame whose from changes within one contact must be dropped as IdentityChanged and the peer's addressed mail must not be handed over to the new identity. → pinned in Task 9.
  3. A signature with an unknown algorithm id, or the wrong length for a known one — the first must be carried and forwarded untouched; the second (and algo = 0 and version = 0) is Malformed, including when the observation is rebuilt from fragments. → pinned in Task 4.
  4. A store opened with unknown downtime (no clock marker, or a wall clock that went backwards) must expire held addressed messages (never deliver a directive that may have outlived lifetime_ms) and floor held broadcasts at unknown_downtime_age_ms, not deliver either as-is. → pinned in Task 10.
  5. A valid frame with trailing bytes must be rejected as Malformed (canonical decode), and a frame whose decode finds the from field missing must not panic. → pinned in Task 9.
  6. Every frame must fit mtu() after the +1 version byte and the +67-byte signature — swept across MTUs from the BLE floor (MIN_MTU = 23) to MAX_FRAME, with an oversized observation given up cleanly as TooLarge, never sent truncated. → pinned in Task 4.

File Map (decomposition locked in)

  • tines-core/src/wire.rs — envelope encode/decode, frame_sender; Frame/WireError become private in Task 5.
  • tines-core/src/observation.rs — Observation, Dest, Signature, SigAlgo, NodeId, ObservationId, Tick, Topic, Payload.
  • tines-core/src/consts.rs — WIRE_VERSION + the wire/local split.
  • tines-core/src/exchange.rs — drop counters, NodeEvent, peer pinning, hardening. Stays private (pub(crate) from Task 5).
  • tines-core/src/node.rs — Node facade: flush(now), drain_events -> NodeEvent.
  • tines-core/src/store.rs — Store trait: is_empty, flush; the store_conformance module.
  • tines-core/src/lib.rs — the 1.0 public surface + #![deny(missing_docs)].
  • tines-core/tests/vectors.rs — pinned identity + golden wire vectors.
  • tines-store/src/log.rs — format v2 + wall-clock marker + downtime fold.
  • tines-sim/src/capped.rs — the CappedStore wrapper (every Store change goes through it).
  • tines/src/ipc.rs, tines-ros2/src/node.rs — host consumers of NodeEvent/flush/observed_at.
  • tines-bt/src/transport.rs — frame_sender import site.
  • fuzz/ — cargo-fuzz target (outside the workspace).

Task 1: Wire envelope — WIRE_VERSION byte

Files:

  • Modify: tines-core/src/consts.rs (add WIRE_VERSION)
  • Modify: tines-core/src/wire.rs (encode/decode prepend+check the byte; frame_sender becomes version-aware; update the golden Summary vector; keep the pre-change Summary bytes as a 0.x-rejection vector)
  • Modify: tines-core/src/exchange.rs (SUMMARY_OVERHEAD 24 → 25)
  • Modify: tines-core/src/frag.rs (FRAG_OVERHEAD 64 → 65)
  • Modify: tines-bt/tests/transport.rs, tines-bt/tests/adv.rs (the frame(from, payload) helpers prepend WIRE_VERSION)

Interfaces:

  • Produces: pub const WIRE_VERSION: u8 = 4; (decided: 4 is the first value in 4–0x7F, a single-byte postcard tag, ≥ the 0.x tags 0–3); WireError gains VersionMismatch { version: u8 }; frame_sender(bytes: &[u8]) -> Option<NodeId> now skips two leading bytes (version + variant tag) and returns None when bytes[0] != WIRE_VERSION.

  • Step 1: Write the failing tests

In tines-core/src/wire.rs (mod tests): encode_prepends_version_byte (encode a Summary, assert buf[0] == WIRE_VERSION and Frame::decode(&buf[..n]) round-trips — do not decode &buf[1..], which is missing the version byte); decode_wrong_version_is_mismatch (a frame with buf[0] = WIRE_VERSION + 1 returns Err(WireError::VersionMismatch { version })); decode_empty_is_malformed (Frame::decode(&[]) returns Err(WireError::Malformed), not a panic and not VersionMismatch { version: 0 }); rejects_0x_frame (the pre-change 0.x Summary golden bytes — captured from the current summary_golden_vector literal before editing it — are rejected as VersionMismatch { version: 0 }); frame_sender_skips_version_and_tag (bytes [WIRE_VERSION, 0, from..] yields Some(from)); frame_sender_wrong_version_is_none ([WIRE_VERSION+1, 0, from..] → None).

  • Step 2: Run, expect failure — cargo test -p tines-core wire fails on the new names/behaviour.

  • Step 3: Implement — add WIRE_VERSION; in Frame::encode write out[0] = WIRE_VERSION then postcard into out[1..] (return 1 + len, TooLarge if out.len() < 1 + len); in Frame::decode check bytes.is_empty() → Malformed, then bytes[0] != WIRE_VERSION → Err(VersionMismatch { version: bytes[0] }), then postcard the remainder (the empty-remainder/canonical check is not yet enforced — that is Task 9); rewrite frame_sender to ENVELOPE_OVERHEAD = 2.

  • Step 4: Run, expect pass — cargo test -p tines-core green; then update the tines-bt test helpers and re-run cargo test -p tines-bt.

  • Step 5: Commit — git commit -m "feat(core): WIRE_VERSION byte on every frame".

Task 2: Drop reporting — NodeEvent and the drop counter table (§1, §3 events)

Files:

  • Modify: tines-core/src/exchange.rs (rename ConfidenceEvent → NodeEvent<A>; add DropReason, the counter table, the overflow counter; report drops in on_frame; drain_events returns NodeEvent)
  • Modify: tines-core/src/node.rs (drain_events signature)
  • Modify: tines-core/src/lib.rs (export NodeEvent, DropReason; drop ConfidenceEvent)
  • Modify: tines-core/src/consts.rs (DROP_TABLE_CAP)
  • Modify: tines/src/ipc.rs (map NodeEvent::Delivered into EventOut; log NodeEvent::Dropped to stderr)
  • Modify: tines-ros2/src/node.rs + lib.rs (match NodeEvent; fix the doc-comment links to ConfidenceEvent)

Interfaces:

  • Consumes: WireError::VersionMismatch (Task 1).

  • Produces: #[non_exhaustive] pub enum NodeEvent<A> { Delivered { peer: NodeId, id: ObservationId }, Dropped { addr: Option<A>, reason: DropReason, count: u32 } }; #[non_exhaustive] pub enum DropReason { VersionMismatch { version: u8 }, Malformed } (IdentityChanged arrives in Task 9); pub const DROP_TABLE_CAP: usize = 32; (local const); Node::drain_events(&mut self) -> heapless::Vec<NodeEvent<T::Addr>, { EVENT_QUEUE_CAP + DROP_TABLE_CAP + 1 }>. Decided: the return buffer is sized EVENT_QUEUE_CAP + DROP_TABLE_CAP + 1 (64 Delivered + 32 counted Drops + 1 overflow slot) so drops always have room; the table itself is DROP_TABLE_CAP, so a drop counter that would exceed the buffer is impossible.

  • Step 1: Write the failing tests — in exchange.rs (mod tests): version_mismatch_is_counted (drive one frame with a bad version byte from addr, drain, assert exactly one Dropped { addr: Some(addr), reason: VersionMismatch{..}, count: 1 }); malformed_is_counted (garbage bytes → Dropped { reason: Malformed }); same_drop_increments_count (two bad frames → one Dropped with count: 2, drained once); drops_do_not_evict_delivered (fill the event queue with Delivered, add a Dropped, assert all Delivered still present and the Dropped is reported); overflow_counter_has_no_addr (drive > DROP_TABLE_CAP distinct (addr, reason) pairs, assert the last is reported with addr: None); expired_addressed_is_not_a_drop (an addressed observation that arrives already past its lifetime_ms produces no Dropped).

  • Step 2: Run, expect failure — cargo test -p tines-core exchange fails on NodeEvent/DropReason names.

  • Step 3: Implement — rename to NodeEvent<A> (two variants above); add the counter table (fixed heapless::Vec<(A, DropReason, u32), DROP_TABLE_CAP> or equivalent) + one Option<(DropReason, u32)> overflow slot; in on_frame, replace the Err(_) => return None arm with a version-mismatch vs malformed branch that bumps the table; drain_events returns queued Delivered first, then one Dropped per nonzero counter (resetting it). Expired addressed observations are dropped by policy in accept/reject, not counted.

  • Step 4: Run, expect pass — cargo test -p tines-core, then cargo test -p tines, then docker build -f Dockerfile.ros2 -t tines-ros2-check . (tines-ros2 consumers).

  • Step 5: Commit — git commit -m "feat(core): NodeEvent with counted drop reporting".

Task 3: Dest — the addressing reshape (§2) + the log format bump (D5)

Files:

  • Modify: tines-core/src/observation.rs (Dest; Observation.dest replaces to/lifetime_ms; id(); aged(); constructors broadcast/addressed/with_observed_at; the age_ms lower-bound and observed_at rustdoc; update the module's own tests)
  • Modify: tines-core/src/node.rs (observe/observe_to build via dest; belief reads dest), store.rs (docs), mem_store.rs, exchange.rs (every obs.to/obs.lifetime_ms → obs.dest; reject), wire.rs (Obs variant + tests)
  • Modify: tines-store/src/log.rs (bump format_version; refuse the old version on open)
  • Modify: tines-store/tests/persistence.rs (move old_format_is_refused here from Task 10)
  • Modify: tines-sim/src/capped.rs (re-verify it compiles), tines-sim/src/sim.rs/world.rs (any to:/lifetime_ms: construction), tines/src/ipc.rs, tines-ros2/src/node.rs

Interfaces:

  • Consumes: nothing new.

  • Produces: #[non_exhaustive] pub struct Dest { pub to: NodeId, pub lifetime_ms: u64 }; Observation { pub observer, pub seq, pub age_ms, pub observed_at, pub topic, pub payload, pub dest: Option<Dest> } (sig is added by Task 4 — this task's Observation has no sig field yet); Observation::broadcast(observer, seq, topic, payload) -> Observation, Observation::addressed(observer, seq, topic, payload, to, lifetime_ms) -> Observation, Observation::with_observed_at(mut self, at: u64) -> Observation. tines-store's format_version is bumped here (D5: old logs are refused, not migrated).

  • Step 1: Write the failing tests — in observation.rs: dest_invariant_by_type (an addressed observation always has dest: Some); id_reads_dest_to (obs(7,0,None,Some([2;16]),Some(100)).id() == obs(7,0,None,Some([2;16]),Some(200)).id() — lifetime still not part of identity — and the pinned id_known_vector value is unchanged); constructors_set_age_zero (broadcast/addressed produce age_ms == 0 and the right dest); with_observed_at_sets_field. In tines-store/tests/persistence.rs: old_format_is_refused (a v1 log → Err naming the fix; moved from Task 10, since the encoding changes here).

  • Step 2: Run, expect failure — cargo test -p tines-core observation fails on dest.

  • Step 3: Implement — mechanical: dest: Option<Dest> replaces to: Option<NodeId> + lifetime_ms: Option<u64>; id() folds self.dest.map(|d| d.to) (marker bytes unchanged); reject becomes obs.dest.is_some_and(|d| obs.age_ms > d.lifetime_ms) (the malformed-pair branch and its tests are deleted); write the two rustdoc items now — age_ms is a lower bound (it counts time on running clocks only; power-off time is recovered on store open, crash time is bounded by the host flush cadence, in-flight time is negligible) and points to observed_at, and observed_at is the observer's wall-clock time in ms since the Unix epoch (UTC), None if the observer has no trusted clock, never changed by relays. Bump tines-store's format_version and make open refuse any older version. Update every downstream read/construct; cargo check --workspace and fix all fallout.

  • Step 4: Run, expect pass — cargo test --workspace green; cargo build --workspace green.

  • Step 5: Commit — git commit -m "feat(core): Dest type replaces to + lifetime_ms".

Task 4: Signature + SigAlgo — the reserved slot (§2a)

Files:

  • Modify: tines-core/src/observation.rs (Signature, SigAlgo, Observation.sig, MAX_SIG; the "signing and verification arrive in 1.1" rustdoc)
  • Modify: tines-core/src/consts.rs (MAX_SIG = 64 in the wire group)
  • Modify: tines-core/src/wire.rs (Obs/Frag encode/decode of sig; the golden Obs vector)
  • Modify: tines-core/tests/vectors.rs (golden Obs vector, both the Ed25519-shaped and unknown-algo cases)

Interfaces:

  • Consumes: Observation (Task 3).

  • Produces: pub const MAX_SIG: usize = 64;; #[non_exhaustive] pub struct Signature { pub version: u8, pub algo: SigAlgo, pub bytes: heapless::Vec<u8, MAX_SIG> }; #[non_exhaustive] #[repr(u8)] pub enum SigAlgo { Ed25519 = 1, Unknown(u8) } (decided: catch-all is Unknown(u8)); algo serializes as a raw u8 on the wire, not a postcard enum tag. The rustdoc for Signature, SigAlgo and Observation::sig says "signing and verification arrive in 1.1" in those words.

  • Step 1: Write the failing tests — in wire.rs/vectors.rs: obs_roundtrip_with_ed25519_signature (an Observation carrying Signature { version: 1, algo: Ed25519, bytes: 64 bytes } decodes equal); obs_roundtrip_with_unknown_algo (algo: Unknown(7), short bytes, decodes and is forwarded, not rejected); algo_zero_is_malformed (algo: Unknown(0) → Malformed); version_zero_is_malformed (version: 0 → Malformed); raw_one_decodes_to_ed25519 (a wire algo byte 1 decodes to SigAlgo::Ed25519, never Unknown(1)); ed25519_wrong_length_is_malformed (algo: Ed25519, 63 bytes → Malformed); sig_excluded_from_id (two observations identical except sig share one id()); reassembled_bad_signature_is_malformed (an observation whose fragments reassemble to a bad-signature Observation is rejected as Malformed, not accepted); fragmentation_fits_mtu_sweep (Review Focus #6 — see below).

    fragmentation_fits_mtu_sweep: for mtu in MIN_MTU(=23)..=MAX_FRAME, a maximum-size signed observation (full MAX_PAYLOAD + 64-byte signature) must either fragment into ≤ MAX_FRAGMENTS (64) frames each ≤ mtu, or — below the threshold (~85 bytes) — be given up cleanly (SendError::TooLarge / ObserveError::TooLarge), never emitted oversized.

  • Step 2: Run, expect failure — tests fail on missing Signature/SigAlgo.

  • Step 3: Implement — the types above; sig: Option<Signature> on Observation (no public setter in 1.0; core's decode sets it); wire: algo as raw u8 (custom Serialize/Deserialize or a #[serde(with = "…")] module that writes the repr(u8) discriminant; Unknown(n) writes n); version = 0 and algo = 0 and a wrong length for Ed25519 are Malformed; id() ignores sig; the same decode-level checks run on observations rebuilt from fragments.

  • Step 4: Run, expect pass — cargo test -p tines-core green; pin the golden Obs vector.

  • Step 5: Commit — git commit -m "feat(core): reserved Signature slot on Observation".

Task 5: Public API narrowing (§3)

Files:

  • Modify: tines-core/src/lib.rs (the 1.0 export list; mod wire; private, pub use wire::frame_sender;)
  • Modify: tines-core/src/wire.rs, exchange.rs, frag.rs, bloom.rs (mark the non-API items pub(crate))
  • Modify: tines-core/src/observation.rs, store.rs, transport.rs, node.rs (add #[non_exhaustive] where §3 requires)
  • Modify: tines-sim/src/world.rs (fix the [Exchange] doc link), tines-bt/src/transport.rs (import tines_core::frame_sender)
  • Modify: tines-bt/tests/transport.rs, adv.rs (import path)

Interfaces:

  • Consumes: all prior types.

  • Produces: the exact §3 public list. Frame, WireError, Exchange, Peer, InFlight, Bloom (and its salt field), Reassembler, fragment, FRAG_OVERHEAD are pub(crate). #[non_exhaustive] on ObserveError, StoreError, SendError, Event, NodeEvent, DropReason, SigAlgo, Observation, Dest, Signature, TrimPolicy. pub use wire::frame_sender; at crate root (decided over keeping wire public).

  • Step 1: Write the failing tests — a compile-time doc/export check: in tines-core/tests/public_api.rs (or the conformance feature), a pub use-only probe that uses each item on the §3 public list and asserts nothing; the failing signal is cargo check -p tines-core (private items are no longer importable — existing external callers break, which the plan fixes in this task).

  • Step 2: Run, expect failure — cargo check --workspace fails on tines-sim/tines-bt importing now-private items.

  • Step 3: Implement — apply pub(crate)/pub and #[non_exhaustive] exactly per §3; fix the two external doc-link/import sites; run cargo doc --workspace --no-deps to confirm no broken private-link warnings.

  • Step 4: Run, expect pass — cargo check --workspace + cargo test --workspace green.

  • Step 5: Commit — git commit -m "refactor(core): narrow the 1.0 public API".

Task 6: The Store trait, settled (§4)

Files:

  • Modify: tines-core/src/store.rs (is_empty default, flush default, release docs, new store_conformance module behind a conformance feature)
  • Modify: tines-core/src/node.rs (flush(now) replaces store_mut)
  • Modify: tines-core/Cargo.toml (conformance feature)
  • Modify: tines-store/src/log.rs (implement flush), tines-sim/src/capped.rs (delegate/implement is_empty/flush), tines/src/ipc.rs (call node.flush(now) in poll_exchange, around line 214)

Interfaces:

  • Consumes: nothing new.

  • Produces: Store::is_empty(&self) -> bool (default self.len() == 0; drop the len_without_is_empty allow); Store::flush(&mut self, now: Tick) -> Result<(), StoreError> (default Ok(())); Node::flush(&mut self, now: Tick) -> Result<(), StoreError>; Node::store_mut removed. A conformance feature gating tines_core::store_conformance — a no_std-clean module of functions that take a backend factory (not a tines-core/tests/ file), so they can build observations with sig set from inside the crate; each function exercises one property (dedupe, age lineage, saturating ticks, release-surviving-get, sig round-trip, trim order, no tombstones, idempotent flush). MemStore, LogStore (a fresh file per case) and CappedStore call the module from their own crates.

  • Step 1: Write the failing tests — the store_conformance module itself, with one function per property; then wire MemStore, tines_store::LogStore and tines_sim::CappedStore to it from their own crates.

  • Step 2: Run, expect failure — cargo test -p tines-core --features conformance fails on flush/is_empty/store_conformance.

  • Step 3: Implement — trait defaults; Node::flush(now) delegating to Store::flush; LogStore::flush(&mut self, now) writing a Record::Clock with the caller's now (fixing F18's stale-marker tick); delete store_mut; update poll_exchange in tines/src/ipc.rs to node.flush(clock.now()).

  • Step 4: Run, expect pass — cargo test --workspace + docker build -f Dockerfile.ros2 -t tines-ros2-check ..

  • Step 5: Commit — git commit -m "feat(core): settle the Store trait (flush, is_empty, conformance suite)".

Task 7: Constants — wire vs local split (§5)

Files:

  • Modify: tines-core/src/consts.rs (re-group + document each const as wire or local)
  • Modify: AGENTS.md (the constant list shows the split)

Interfaces:

  • Produces: consts.rs split into two documented groups — wire (frozen for v1: MAX_TOPIC, MAX_PAYLOAD, MAX_FRAME, SUMMARY_BYTES, ACK_IDS_CAP, MAX_SIG, BLOOM_K, and the private MAX_FRAGMENTS) vs local (MAX_PEERS, SEND_QUEUE_CAP, ACK_BUFFER_CAP, BCAST_SENT_CAP, EVENT_QUEUE_CAP, SUMMARY_INTERVAL_MS, REASSEMBLY_TIMEOUT_MS, DROP_TABLE_CAP).

  • Step 1: No new tests — this task only re-documents; the test is cargo doc --workspace --no-deps staying clean and the AGENTS.md list matching consts.rs.

  • Step 2: Implement — move + document per §5's rule ("wire if changing it changes how a v1 peer decodes or interprets a frame").

  • Step 3: Verify — cargo test --workspace green (no behaviour change).

  • Step 4: Commit — git commit -m "docs(core): split wire vs local constants".

Task 8: Bounce-table overflow, pinned (§6)

Files:

  • Modify: tines-sim/src/sim.rs, tines-sim/tests/props.rs (a property driving the table past SEND_QUEUE_CAP)

Interfaces:

  • Consumes: the exchange's bounce table (unchanged).

  • Produces: a proptest that floods > SEND_QUEUE_CAP distinct observations across MAX_PEERS peers and asserts the "always ≥ 1 holder" invariant and that the custody cursor still gives every candidate a turn.

  • Step 1: Write the failing test — tines-sim/tests/props.rs::bounce_overflow_keeps_a_holder (drive custody past capacity; assert ≥1 holder for each id and no candidate starved).

  • Step 2: Run, expect pass or a real finding — this pins behaviour; if it fails, the finding is the deliverable (do not "fix" by weakening the invariant).

  • Step 3: Implement any needed fix — only if the property exposes a genuine ≥1-holder violation; §6 says the current drop-oldest design is already safe, so expect pass-as-is.

  • Step 4: Commit — git commit -m "test(sim): pin bounce-table overflow behaviour".

Task 9: Core hardening — F6, F8, F9, F13 (§10)

Files:

  • Modify: tines-core/src/exchange.rs (peer id pinning; idle eviction; summary replay limit; drop-oldest events; pending-ack purge; canonical decode)
  • Modify: tines-core/src/consts.rs (PEER_IDLE_MS = 30000, local)

Interfaces:

  • Consumes: NodeEvent, DropReason (Task 2).

  • Produces: DropReason::IdentityChanged; pub const PEER_IDLE_MS: u64 = 30_000;.

  • Step 1: Write the failing tests — id_changes_are_dropped_as_identity_changed (frame 1 sets peer.id = A, frame 2 claims from = B → dropped, counted IdentityChanged, and B receives no addressed mail); idle_peer_is_evicted (a peer with no inbound frame for > PEER_IDLE_MS is removed as if Lost); replayed_summary_is_ignored (two summaries from one peer within SUMMARY_INTERVAL_MS → the second is not acted on and produces no re-send); events_drop_oldest (overflow the event queue → the oldest Delivered is gone, newest retained); lost_peer_purges_pending_acks (pending acks to a removed peer are cleared); trailing_bytes_is_malformed (valid frame + one trailing byte → Malformed, Review Focus #5).

  • Step 2: Run, expect failure — each test fails against current behaviour.

  • Step 3: Implement — F6: first decoded frame pins peer.id, later from mismatch → IdentityChanged drop (reset on Contact/Lost); F8: track last-inbound tick, evict at PEER_IDLE_MS; F9: at most one summary per peer per SUMMARY_INTERVAL_MS, and skip already-sent ids on rebuild; F13: events.push drops the oldest (remove(0)), purge acks on Lost/evict, take_from_bytes + empty-remainder check.

  • Step 4: Run, expect pass — cargo test -p tines-core + full sh scripts/ci.sh.

  • Step 5: Commit — git commit -m "fix(core): peer pinning, idle eviction, replay and queue hardening".

Task 10: tines-store wall-clock marker + age across downtime (§9, decision G as amended)

Files:

  • Modify: tines-store/src/log.rs (wall-clock marker with derived wall time; open(path, now, wall_now); downtime fold; addressed expiry; broadcast floor)
  • Modify: tines-store/tests/persistence.rs (downtime tests, reworked per the amendment)
  • Modify: tines/src/main.rs + tines/src/ipc.rs, tines-ros2/src/node.rs (hosts set observed_at and wait for a trusted time source before LogStore::open)

Interfaces:

  • Consumes: Observation with observed_at (Task 3), Store::flush(now) (Task 6), the format_version bump (Task 3).

  • Produces: LogStore::open(path: &Path, now: Tick, wall_now: u64) -> Result<Self, StoreError> — a plain u64 (ms since the Unix epoch), not Option. LogStore records wall time and tick at open; each flush(now) clock marker carries wall_at_open + (now − tick_at_open). "Downtime unknown" now means: the log has no clock marker (the previous run never flushed), or the wall clock went backwards (marker wall time ahead of wall_now). A LogStore option for unknown_downtime_age_ms floors held broadcasts.

  • Step 1: Write the failing tests — downtime_known_advances_ages (marker at wall 1000, reopen at 2000 → every held age +1000, saturating); downtime_unknown_expires_addressed (a log with no marker → held addressed obs never delivered, count reported once); downtime_unknown_floors_broadcasts (no marker → held broadcast age raised to unknown_downtime_age_ms); clock_went_backwards_is_unknown (marker wall time ahead of wall_now → treated as unknown); marker_wall_time_is_derived (open at wall W, tick T; flush at tick T+5000 → the marker carries W+5000); open_reports_downtime_once (a fold/unknown-downtime case is reported once on stderr plus a counter, like skipped_records).

  • Step 2: Run, expect failure — cargo test -p tines-store fails on the new signature/behaviour.

  • Step 3: Implement — per §9 as amended: hosts wait for a trusted time source (an RTC they trust, or a GPS fix) before open, and set observed_at to Some(wall ms) on every observation they make; unknown_downtime_age_ms is configured below the host's trim max_age_ms — since the trim defaults are still empty (host hardening), record that this floor's max_age_ms check waits for host hardening where it isn't configurable yet. Add a per-host test that an observation made through the IPC carries Some(wall ms).

  • Step 4: Run, expect pass — cargo test -p tines-store + cargo test -p tines + docker build -f Dockerfile.ros2 -t tines-ros2-check ..

  • Step 5: Commit — git commit -m "feat(store): wall-clock marker and downtime fold".

Task 11: Evidence — pinned vectors + MSRV (§7)

Files:

  • Create: tines-core/tests/vectors.rs (consolidate id_known_vector + the golden wire vectors)
  • Modify: tines-core/Cargo.toml (rust-version on tines-core only), scripts/ci.sh (MSRV job building only tines-core)

Interfaces:

  • Consumes: final wire encodings (Tasks 1–4).

  • Produces: tines-core/tests/vectors.rs with the identity vector (unchanged value) + one golden encoding per Frame variant including the version byte + the Ed25519/unknown-algo Obs vectors + the 0.x-shaped frame rejected as version mismatch; rust-version set on tines-core only (the host crates' deps — bluer, tokio, rclrs — need newer Rust; do not claim tines-core's MSRV for them); a CI job building/testing tines-core alone on that MSRV.

  • Step 1: Write the failing tests — move the existing vectors in (the id_known_vector and the golden encodings); add the missing golden encodings and the 0.x-rejection vector.

  • Step 2: Run, expect pass — cargo test -p tines-core vectors green.

  • Step 3: Implement — find MSRV with cargo msrv searching up to the pinned toolchain (1.99; a repr(u8) enum with a data-carrying variant like SigAlgo needs ≥ 1.66, and dependencies set their own floors — don't guess the range); set rust-version; add the CI job.

  • Step 4: Commit — git commit -m "test(core): pinned vectors + MSRV gate".

Task 12: Evidence — fuzz harness + proptest twin (§7)

Files:

  • Create: fuzz/ (cargo-fuzz, outside the workspace, nightly), fuzz/Cargo.toml, fuzz/fuzz_targets/node.rs
  • Modify: tines-core/tests/ (the proptest stable twin of the same harness)
  • Modify: .github/workflows/ (scheduled fuzz job)

Interfaces:

  • Consumes: Node, MemStore, a scripted Transport.

  • Produces: a fuzz target whose input decodes into Contact/Lost/Frame(bytes)/Tick(delta, may go backwards)/observe steps; an adversarial-peer mode building well-formed frames (chosen summary bits, Obs/Frag ids/observers/ages/lifetimes, acks for sent/unsent ids, conflicting fragments, changing from) from many addresses beyond MAX_PEERS; post-step checks (no panic, ≤ mtu(), no addressed stored past lifetime, drop counts reconcile, signatures round-trip). A proptest twin with a bounded case count runs in the normal suite.

    Adversarial mode must also check the §10 properties: the peer table recovers after a flood from more than MAX_PEERS addresses; output per received summary is bounded and repeating a summary does not re-send; a NodeId never changes within a contact; the event queue drops the oldest; an ack is sent only for an observation that was stored (F1).

  • Step 1: Write the harness — the twin first (it runs in CI); then the fuzz target wrapping it.

  • Step 2: Run — cargo test -p tines-core (twin green); a short cargo fuzz run node -- -max_total_time=60 sanity run (full 24 CPU-hour budget is release-time, §"Done means").

  • Step 3: Commit — git commit -m "test(core): fuzz harness and proptest twin" (committing the corpus too).

Task 13: Docs, compatibility policy, and the 1.0.0 release (§8, "Done means")

Files:

  • Modify: README.md (a "Stability" section with §8; switch the TrimPolicy example to TrimPolicy::default() + field assignment, since the struct is now #[non_exhaustive]), CHANGELOG.md (1.0.0 entry), AGENTS.md (spec list + trait extension rule + wire/local split), tines-core/src/lib.rs (#![deny(missing_docs)] + document the remaining items), THREAT_MODEL.md (mark F6/F8/F9/F13 fixed with SHAs, and update F18 — Task 6 fixed its stale clock-marker tick), Cargo.toml + dependent crates (tines-core → 1.0.0, hosts depend on "1")

Interfaces:

  • Consumes: everything above.

  • Produces: README §8; CHANGELOG 1.0.0 listing every 0.3 break; #![deny(missing_docs)] with zero missing items (recount — the spec's "26" predates the fleet fixes); THREAT_MODEL.md marks F6/F8/F9/F13 fixed (and updates F18); tines-core at 1.0.0 with tines-store/tines-bt/tines-ros2/tines depending on "1".

  • Step 1: Write the failing test — cargo doc --workspace --no-deps under #![deny(missing_docs)] (it fails until every public item is documented).

  • Step 2: Implement — the docs; the release bump.

  • Step 3: Verify — sh scripts/ci.sh green; THREAT_MODEL.md marks F6/F8/F9/F13 fixed with commit SHAs.

  • Step 4: Commit — git commit -m "release(core): tines-core 1.0.0".

    Publishing is Kyle's step (it is outward-facing). The cargo-semver-checks gate is not added here: before 1.0.0 is published the only baseline is 0.2.1, and the check fails on every intended break. It is a follow-up commit after publishing, turning cargo-semver-checks against the published 1.0.0 on in scripts/ci.sh.