Files
tines/docs/superpowers/plans/2026-10-20-tines-core-1-0.md
kyleandClaude Opus 5.5 c3be9d0f83 docs(spec,plan): decision G — quarantine on open, time from peers
The store opens before trusted time. Observations already in the log
are quarantined (not summarised, offered, believed or age-trimmed;
addressed ones report released) until LogStore::set_wall_clock runs the
downtime fold. Trusted time comes from the robot's RTC/GPS, else a
peer's observed_at + age_ms (smallest age wins), else the robot's own
fail-safe. Supersedes "don't open the store until trusted time".

Plan: Task 10 rewritten (quarantine, set_wall_clock, FoldReport,
derived marker wall time); new Task 10b (host trusted-time state
machine, time from peers, observed_at).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 01:53:55 -07:00

383 lines
40 KiB
Markdown
Raw Permalink 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 twice on 2026-10-20**: the store opens *before* trusted time (`LogStore::open(path, now)`, unchanged), with everything already in the log **quarantined** until `LogStore::set_wall_clock(wall_now: u64, now)` runs the downtime fold. Trusted time comes from the robot's own RTC/GPS, else from a peer's observation (`observed_at + age_ms`, smallest `age_ms` wins), else the robot's own fail-safe (outside tines). The marker's wall time is derived (`wall_at_set + (now − tick_at_set)`). This supersedes "open only once trusted time exists".
- `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. **Held data must not leak before the fold, and unknown downtime must take the safe path.**
- Before `set_wall_clock`, a quarantined observation must not appear in a summary, a custody offer or a belief, and a quarantined addressed duplicate must not be acked.
- When the downtime is unknown (no marker, a marker without wall time, or a wall clock that went backwards), held addressed messages expire (a directive that may have outlived `lifetime_ms` is never delivered), and held broadcasts are floored at `unknown_downtime_age_ms`. Neither is delivered 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 + quarantine + `set_wall_clock` 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 quarantine, wall-clock marker and downtime fold (§9, decision G as amended)
**Files:**
- Modify: `tines-store/src/log.rs` (quarantine of held observations; `set_wall_clock`; wall-clock marker with derived wall time; downtime fold; addressed expiry; broadcast floor; fold report)
- Modify: `tines-store/tests/persistence.rs` (quarantine and downtime tests)
**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)` keeps its signature. Every observation already in the log is quarantined.
- While quarantined, an observation is:
- left out of `for_each_broadcast_id`, `for_each_addressed_id` and `for_each_topic`;
- reported `true` by `is_released` if addressed;
- not trimmed on its age;
- still reported by `contains`, so dedupe works.
- `LogStore::set_wall_clock(&mut self, wall_now: u64, now: Tick) -> FoldReport`. It runs once (a second call is a no-op), folds the downtime, lifts the quarantine, and starts wall time for markers.
- `FoldReport { downtime_ms: Option<u64>, expired_addressed: usize, floored_broadcasts: usize }`. It's also printed once on stderr and readable afterwards, like `skipped_records`.
- `Record::Clock` gains `wall: Option<u64>`. After `set_wall_clock` it is `wall_at_set + (now − tick_at_set)`; before, `None`.
- A `LogStore` option `unknown_downtime_age_ms`.
- **Downtime known:** the last marker has a wall time, and `wall_now` ≥ it. Every quarantined observation's age gains the wall time elapsed since that marker, minus the ticks already counted since this open (saturating).
- **Downtime unknown:** no marker, a marker without wall time, or a marker ahead of `wall_now`. Quarantined addressed observations get an age past `lifetime_ms`, and quarantined broadcasts get `max(age, unknown_downtime_age_ms)`.
- [ ] **Step 1: Write the failing tests**
- `held_is_quarantined_until_wall_clock`: reopen a log holding a broadcast and an addressed observation. Before `set_wall_clock`, neither is visited by `for_each_broadcast_id`, `for_each_addressed_id` or `for_each_topic`; the addressed one is `is_released`; both are `contains`. After it, they're all visible.
- `quarantined_addressed_duplicate_not_acked`: an exchange-level test with `LogStore` (or in `tines-sim` through a quarantining wrapper). A peer offers a duplicate of a quarantined addressed observation, and no `Ack` comes back.
- `new_inserts_not_quarantined`: an observation inserted after `open` is visible at once and isn't changed by the fold.
- `downtime_known_advances_ages`: marker at wall 1000; reopen; `set_wall_clock(2000, …)` adds 1000 to every held age (saturating), less any ticks already counted since open.
- `downtime_unknown_expires_addressed` (no marker) and `marker_without_wall_is_unknown`: held addressed observations are never delivered, and the report counts them.
- `downtime_unknown_floors_broadcasts`: a held broadcast's age is raised to `unknown_downtime_age_ms`.
- `clock_went_backwards_is_unknown`: marker wall time ahead of `wall_now`.
- `marker_wall_time_is_derived`: `set_wall_clock(W, T)`, then `flush(T+5000)` writes a marker carrying W+5000. A flush before `set_wall_clock` writes `wall: None`.
- `set_wall_clock_runs_once`: a second call changes nothing.
- `fold_reported_once`: the `FoldReport` is printed once, and readable afterwards.
- [ ] **Step 2: Run, expect failure** — `cargo test -p tines-store`.
- [ ] **Step 3: Implement** — per §9 as amended. The quarantine is a per-entry flag set at load and cleared by the fold; it isn't persisted. Whether quarantined entries count towards `trim`'s `max_count` is the builder's call, with one rule: untrusted ages must never cause fresher data to be trimmed in their place.
- [ ] **Step 4: Run, expect pass** — `cargo test -p tines-store`, then `sh scripts/ci.sh`.
- [ ] **Step 5: Commit** — `git commit -m "feat(store): quarantine held observations until a wall-clock downtime fold"`.
### Task 10b: Hosts — trusted time, time from peers, `observed_at` (§9, decision G as amended)
**Files:**
- Modify: `tines/src/clock.rs` (`HostClock` gains trusted wall time), `tines/src/main.rs`, `tines/src/ipc.rs`
- Modify: `tines-ros2/src/node.rs`
**Interfaces:**
- Consumes: `LogStore::set_wall_clock` (Task 10).
- Produces, in both hosts:
- **Open at once.** `LogStore::open(path, now)` at startup, as today. The robot runs tines immediately.
- **Trusted time, in this order:**
1. A local source. A config flag (`trusted_clock: "system"`) says the system clock is trusted, meaning an RTC or a GPS/NTP-disciplined clock. This is config, not detection, and the docs say so.
2. Otherwise, a peer. After each `poll`, scan the observations stored *since this open* for the one carrying `observed_at` with the smallest `age_ms`. The estimate is `observed_at + age_ms + (now − tick it was stored)`, which is never later than the true time, because age is a lower bound. Quarantined data is invisible to the scan, so only this boot's observations count.
3. Otherwise, stay quarantined. The robot's own fail-safe is outside tines: log once that tines is waiting for trusted time, and keep running.
- The first trusted time calls `set_wall_clock(wall, now)` once and anchors `HostClock`'s wall time: wall at set + ticks since.
- From then on, every observation the host makes carries `observed_at = Some(HostClock wall ms)`. Before trusted time it stays `None` (R4).
- [ ] **Step 1: Write the failing tests** (in `tines`, over the existing in-memory transport)
- `observe_has_observed_at_after_trusted_time`: with `trusted_clock` set, an IPC `Observe` produces an observation with `observed_at = Some(_)`.
- `observe_has_no_observed_at_before_trusted_time`: without it, `None`.
- `time_from_peer`: instance A has trusted time and observes; instance B has none and a log holding a quarantined observation. After an exchange, B's `set_wall_clock` has run with an estimate within one poll interval of A's wall time (A's `observed_at + age_ms`), and B's held observation is visible.
- `smallest_age_wins`: given two received observations with `observed_at`, the estimate comes from the one with the smaller `age_ms`.
- [ ] **Step 2: Run, expect failure.**
- [ ] **Step 3: Implement** — `HostClock` wall anchor; the trusted-time state machine (waiting → set) in the service's poll loop and in `TinesNode::poll`; the `trusted_clock` config field; `observed_at` on every observe once trusted. `unknown_downtime_age_ms` is a host config field passed to `LogStore`. It must be below the trim `max_age_ms`, but the trim defaults are still empty (host hardening), so note in the host docs that this check waits for host hardening.
- [ ] **Step 4: Run, expect pass** — `cargo test -p tines`, `sh scripts/ci.sh`, then `docker build -f Dockerfile.ros2 -t tines-ros2-check .`.
- [ ] **Step 5: Commit** — `git commit -m "feat(hosts): trusted wall time from the system clock or a peer; set observed_at"`.
### 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; update F18 — Task 6 fixed its stale clock-marker tick; add a finding for peer-derived time: unauthenticated until 1.1, a forged time ahead only expires early, behind takes the unknown path, but the robot's own `observed_at` stamps are wrong until a local source corrects them), `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`.