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

328 lines
35 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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 `use`s 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`.