docs(plan): apply 1.0 review round 1
This commit is contained in:
@@ -8,7 +8,7 @@
|
||||
|
||||
**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`.
|
||||
**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
|
||||
|
||||
@@ -17,23 +17,23 @@ 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`.
|
||||
- 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 five failure modes the spec implies but no single task's tests exercise, most likely to bite first:
|
||||
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`. → pinned in Task 2.
|
||||
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`) 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.
|
||||
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, including a maximum-size signed observation, still fits `mtu()`** after the +1 version byte and the +67-byte signature. → pinned in Task 4.
|
||||
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.
|
||||
|
||||
---
|
||||
|
||||
@@ -44,7 +44,7 @@ The five failure modes the spec implies but no single task's tests exercise, mos
|
||||
- `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/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.
|
||||
@@ -59,7 +59,7 @@ The five failure modes the spec implies but no single task's tests exercise, mos
|
||||
|
||||
**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/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`)
|
||||
@@ -69,11 +69,11 @@ The five failure modes the spec implies but no single task's tests exercise, mos
|
||||
|
||||
- [ ] **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`).
|
||||
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[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 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`.
|
||||
|
||||
@@ -86,39 +86,41 @@ In `tines-core/src/wire.rs` (mod tests): `encode_prepends_version_byte` (encode
|
||||
- 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/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>`.
|
||||
- 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 `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 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).
|
||||
- [ ] **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)
|
||||
### 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`; update the module's own tests)
|
||||
- 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-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`
|
||||
- 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`.
|
||||
- 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`.
|
||||
- [ ] **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); update every downstream read/construct. `cargo check --workspace` and fix all fallout.
|
||||
- [ ] **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.
|
||||
|
||||
@@ -127,20 +129,22 @@ In `tines-core/src/wire.rs` (mod tests): `encode_prepends_version_byte` (encode
|
||||
### 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/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.
|
||||
- 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`); `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 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 a wrong length for `Ed25519` are `Malformed`; `id()` ignores `sig`.
|
||||
- [ ] **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.
|
||||
|
||||
@@ -175,17 +179,17 @@ In `tines-core/src/wire.rs` (mod tests): `encode_prepends_version_byte` (encode
|
||||
- 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)
|
||||
- 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 suite of shared tests exercising dedupe, age lineage, saturating ticks, release-surviving-`get`, `sig` round-trip, trim order, no tombstones, idempotent `flush`).
|
||||
- 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** — `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 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); delete `store_mut`; update `ipc.rs:214` to `node.flush(clock.now())` (and the `PollExchanger` call sites).
|
||||
- [ ] **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 .`.
|
||||
|
||||
@@ -245,42 +249,42 @@ In `tines-core/src/wire.rs` (mod tests): `encode_prepends_version_byte` (encode
|
||||
|
||||
- [ ] **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)
|
||||
### Task 10: tines-store wall-clock marker + age across downtime (§9, decision G as amended)
|
||||
|
||||
**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)
|
||||
- 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).
|
||||
- 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`.
|
||||
- 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` (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 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; 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 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` + `docker build -f Dockerfile.ros2 -t tines-ros2-check .`.
|
||||
- [ ] **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): log v2 with wall-clock marker and downtime fold"`.
|
||||
- [ ] **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: `Cargo.toml` (`[workspace.package] rust-version`), `scripts/ci.sh` (MSRV job)
|
||||
- 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 + 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.
|
||||
- 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; add the missing golden encodings.
|
||||
- [ ] **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 --min 1.70 --max 1.85` (or current range); set `rust-version`; add the CI job.
|
||||
- [ ] **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"`.
|
||||
|
||||
@@ -295,25 +299,29 @@ In `tines-core/src/wire.rs` (mod tests): `encode_prepends_version_byte` (encode
|
||||
- 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"`.
|
||||
- [ ] **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), `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"`)
|
||||
- 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; `cargo-semver-checks` in `scripts/ci.sh`; `tines-core` at 1.0.0 with `tines-store`/`tines-bt`/`tines-ros2`/`tines` depending on `"1"`.
|
||||
- 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; the semver-check gate.
|
||||
- [ ] **Step 2: Implement** — the docs; the release bump.
|
||||
|
||||
- [ ] **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 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`.
|
||||
|
||||
Reference in New Issue
Block a user