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

30 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 (draft v6, approved by Kyle 2026-10-20) — the plan argues from the spec, so the spec travels with it; executors read both. Handoff notes: 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, 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.
  • tines-ros2 is not in the default gate (scripts/ci.sh runs --workspace but the ros2 feature is off; verify any change touching tines-ros2 with docker build -f Dockerfile.ros2 -t tines-ros2-check . — cached image exists).
  • Gate (sh scripts/ci.sh, must be green at every commit): cargo fmt --all -- --check → cargo clippy --workspace --all-targets -- -D warnings → cargo doc --workspace --no-deps → cargo test --workspace → sh scripts/check-no-std.sh.
  • 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 five 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. → 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) is Malformed. → pinned in Task 4.
  4. A store opened with unknown downtime 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, including a maximum-size signed observation, still fits mtu() after the +1 version byte and the +67-byte signature. → 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; 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)
  • 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[1..]) still decodes); decode_wrong_version_is_mismatch (a frame with buf[0] = WIRE_VERSION + 1 returns Err(WireError::VersionMismatch { version })); decode_empty_is_error (Frame::decode(&[]) is an Err, not a panic); 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[0] == WIRE_VERSION (empty or mismatch → Err(VersionMismatch{version: bytes.first().copied().unwrap_or(0)}), then postcard the remainder, empty remainder required 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; NodeEvent::Dropped is ignored by the IPC for now or surfaced as a stderr log)
  • 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>.

  • 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 Delivered still all present and the Dropped is the counter); overflow_counter_has_no_addr (drive > DROP_TABLE_CAP distinct (addr, reason) pairs, assert the last is reported with addr: None).

  • 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).

  • 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)

Files:

  • Modify: tines-core/src/observation.rs (Dest; Observation.dest replaces to/lifetime_ms; id(); aged(); constructors broadcast/addressed/with_observed_at; 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-sim/src/capped.rs (only if it reads addressing — it doesn't, but re-verify it compiles), tines-sim/src/sim.rs/world.rs (any to:/lifetime_ms: construction), tines-store/src/log.rs (Record encoding), 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.

  • 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.

  • 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); 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)
  • 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.

  • 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); ed25519_wrong_length_is_malformed (algo: Ed25519, 63 bytes → Malformed); sig_excluded_from_id (two observations identical except sig share one id()); max_signed_obs_fits_mtu (Review Focus #6: a MAX_PAYLOAD observation with a 64-byte signature encodes within MAX_FRAME).

  • 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 a wrong length for Ed25519 are Malformed; id() ignores sig.

  • 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) at 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 suite of shared tests exercising dedupe, age lineage, saturating ticks, release-surviving-get, sig round-trip, trim order, no tombstones, idempotent flush).

  • Step 1: Write the failing tests — tines-core/tests/store_conformance.rs (feature conformance): each property above as a #[test] that runs against a provided backend; wire MemStore, tines_store::LogStore and tines_sim::CappedStore to it in 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); delete store_mut; update ipc.rs:214 to node.flush(clock.now()) (and the PollExchanger call sites).

  • 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 format v2 + age across downtime (§9, decision G)

Files:

  • Modify: tines-store/src/log.rs (bump format_version, wall-clock marker, open(path, now, wall_now), downtime fold, addressed expiry, broadcast floor, refuse old logs)
  • Modify: tines-store/tests/persistence.rs (downtime tests)
  • Modify: tines/src/main.rs + tines/src/ipc.rs, tines-ros2/src/node.rs (hosts pass observed_at + trusted wall time)

Interfaces:

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

  • Produces: LogStore::open(path: &Path, now: Tick, wall_now: Option<u64>) -> Result<Self, StoreError>; the log header format_version bumps for the new Observation encoding; Record::Clock carries an optional wall-clock ms; a LogStore option for unknown_downtime_age_ms.

  • 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 (missing wall → held addressed obs never delivered, count reported once); downtime_unknown_floors_broadcasts (held broadcast age raised to unknown_downtime_age_ms); old_format_is_refused (a v1 log → Err naming the fix); clock_went_backwards_is_unknown (marker ahead of now → treated as unknown).

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

  • Step 3: Implement — per §9; hosts set observed_at (None → Some(host wall ms)) and pass wall_now; when a host has no trusted clock it passes None and configures the floor.

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

  • Step 5: Commit — git commit -m "feat(store): log v2 with 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: Cargo.toml ([workspace.package] rust-version), scripts/ci.sh (MSRV job)

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 + a 0.x-shaped frame rejected as version mismatch; rust-version set to the lowest version that builds tines-core (found via cargo-msrv, not guessed); a CI job building/testing tines-core on that MSRV.

  • Step 1: Write the failing tests — move the existing vectors in; add the missing golden encodings.

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

  • Step 3: Implement — find MSRV with cargo msrv --min 1.70 --max 1.85 (or current 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.

  • 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".

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

Files:

  • Modify: README.md (a "Stability" section with §8), 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), scripts/ci.sh (cargo-semver-checks gate), 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; cargo-semver-checks in scripts/ci.sh; 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; the semver-check gate.

  • Step 3: Verify — sh scripts/ci.sh green including the semver-checks job; 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".