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

320 lines
30 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` (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 `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)` 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"`.