Files
tines/CHANGELOG.md
T
kyleandClaude Opus 5.5 0cb1ad5360 docs: sweep README, CHANGELOG, THREAT_MODEL and AGENTS after the 1.0 review fixes
- CHANGELOG: tines-core 1.0.0 and the hosts' 0.4.0 are separate,
  unreleased sections. Every core API break is listed (the nine
  pub(crate) items, the private modules and the frame_sender path, the
  local constants per decision K, non_exhaustive types, store_mut). The
  new names (WallClockStore, Node::set_wall_clock, peer_time_estimate,
  flush, IdentityChanged) are called out. The host section covers
  tines::trust, the config changes, decision J, snapshots, and the
  review fixes.
- THREAT_MODEL: F6, F8, F9 and F13 are fixed with their commits. F18 is
  partly fixed: the store side and the flush on first trusted time are
  done, but timed and shutdown flushes are still open. F19 now records
  decision J and the plausibility bounds, and what remains. §9, §10 and
  §11 are updated to match.
- README: Dest and sig in the observation table; flush and is_empty in
  the store table; only the public wire constants are listed; a new
  "Trusted time" section with the config keys and the systemd ordering;
  the MSRV and fuzz CI jobs; frame_sender's new path.
- AGENTS: 1.0 is implemented but not yet published. The constants list
  shows the pub(crate) split, PEER_IDLE_MS, MIN_MTU's role and where
  MAX_FRAGMENTS lives. Notes the hidden conformance module.
- tines-bt README: frame_sender's path, and its own MAX_PEERS.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-22 08:44:09 -07:00

21 KiB
Raw Blame History

Changelog

All notable changes to the tines crates. Versions follow Semantic Versioning. tines-core is 1.x and keeps the compatibility promise in README's "Stability" section. The host crates (tines, tines-store, tines-bt, tines-ros2, tines-sim) stay 0.x, where a minor bump may break the API.

tines-core 1.0.0 — Unreleased

Implemented on feat/tines-core-1-0; not yet tagged or published. The wire format is frozen as wire v1, the public API is narrowed and documented (#![deny(missing_docs)]), and the core hardening from the 1.0 spec (docs/superpowers/specs/2026-10-18-tines-1-0-design.md §10) is in. It also carries the fleet-deployment milestone (handoff receipts) and the fleet fixes, which were never released on their own. Findings F1, F6, F8, F9 and F13 are fixed in core; see THREAT_MODEL.md.

Breaking (from 0.3.0)

  • Wire v1.
    • Every frame now starts with a WIRE_VERSION byte (4). A 0.x-shaped frame is dropped and counted as DropReason::VersionMismatch.
    • The new Ack frame is the last Frame variant (see Added).
    • Frames must be canonical. Trailing bytes after a complete frame, or after a reassembled observation, are rejected and counted as Malformed.
  • Dest replaces to/lifetime_ms.
    • An addressed observation carries dest: Option<Dest>, where Dest { to, lifetime_ms }.
    • The identity hash covers dest.to. It still excludes lifetime_ms, age_ms and observed_at.
  • Signature slot.
    • Observation carries sig: Option<Signature> (version, SigAlgo, and up to MAX_SIG = 64 bytes).
    • 1.0 checks the wire shape and carries signatures verbatim. It neither signs nor verifies; that's 1.1. sig is outside the identity hash.
  • Nine items are now pub(crate):
    • Exchange (and with it Exchange::on_send_error; drive the exchange through Node);
    • Peer;
    • InFlight;
    • Bloom (and its public salt field);
    • Reassembler;
    • fragment;
    • Frame;
    • WireError;
    • FRAG_OVERHEAD.
  • The bloom, exchange, frag and wire modules are private, so paths through them are gone:
    • tines_core::wire::frame_sender is now tines_core::frame_sender;
    • DropReason and NodeEvent are reached at the crate root.
  • Local constants are pub(crate) (spec decision K). They are defaults that a later 1.x release will make configurable:
    • MAX_PEERS, SUMMARY_INTERVAL_MS, REASSEMBLY_TIMEOUT_MS, SEND_QUEUE_CAP, EVENT_QUEUE_CAP, BCAST_SENT_CAP and ACK_BUFFER_CAP are no longer in tines_core::consts;
    • the new local constants, PEER_IDLE_MS, DROP_TABLE_CAP and MIN_MTU, were never public;
    • only the wire constants stay public (see Added).
    • Transport::mtu's rustdoc now states the smallest MTU core can use (85 bytes to carry every valid observation).
  • Eleven types are #[non_exhaustive].
    • Enums (match with a wildcard): ObserveError, StoreError, SendError, Event, NodeEvent, DropReason, SigAlgo.
    • Structs (no struct literals outside core): Observation, Dest, Signature, TrimPolicy.
    • Build an Observation with Observation::broadcast or Observation::addressed, and a TrimPolicy with TrimPolicy::default() plus field assignment.
  • Node::drain_events returns NodeEvent<T::Addr>. This replaces the fleet milestone's unreleased ConfidenceEvent.
  • Node::store_mut is removed. It was added by the unreleased fleet fixes so the service could reach LogStore::flush. Use Node::flush, and Node::set_wall_clock / Node::peer_time_estimate for a WallClockStore. Read access through Node::store stays.
  • MSRV. rust-version = "1.82.0". CI builds tines-core on 1.82, with and without default features and for thumbv7em-none-eabihf. It runs no tests there, because a dev-dependency needs edition 2024. From now on an MSRV bump is a minor release.

Added

  • Handoff receipts. The new Ack frame carries up to ACK_IDS_CAP (90) ids. Acks are sent as the exchange runs, and are accepted only from a peer the id was sent to during the current contact.
    • An ack for an addressed observation moves custody: the sender releases its copy (Store::release) and never offers it again.
    • An ack for a broadcast is confidence only. It surfaces as NodeEvent::Delivered { peer, id } from Node::drain_events.
  • Counted drop reporting. A frame this node can't use isn't dropped silently.
    • The exchange counts it under a DropReason: VersionMismatch, Malformed, or IdentityChanged (F6). The count is keyed by the sender's transport address.
    • Node::drain_events reports each counter as NodeEvent::Dropped { addr, reason, count }, after the queued Delivered events.
    • The counter table is bounded. When it's full, further drops go to one overflow counter, reported with addr: None and the reason of the first drop that overflowed since the last drain. Draining empties the table, keys included.
    • An expired addressed observation that's refused by policy is not counted.
  • Store::flush(now) -> Result<(), StoreError> and Store::is_empty, both with safe defaults, plus Node::flush(now). flush is the store's durability point and its time anchor for ages after a restart. Flushing twice is harmless, and an earlier now is clamped. Core never flushes on its own.
  • WallClockStore (spec decision H). An opt-in supertrait of Store, for backends that hold data across power-off. It has an associated FoldReport, set_wall_clock(wall_now, now) and peer_time_estimate(now). LogStore implements it; MemStore doesn't.
  • Node::set_wall_clock and Node::peer_time_estimate. They exist when S: WallClockStore and delegate to it, so no &mut Store escapes the facade. Core forwards the wall time and never reads it.
  • Dest, Signature and SigAlgo, and the constructors Observation::broadcast, Observation::addressed and Observation::with_observed_at. The two constructors panic on an oversized topic or payload; Node::observe and Node::observe_to return ObserveError::TooLarge instead.
  • tines_core::frame_sender(bytes) -> Option<NodeId>: a frame's claimed sender, for transports that bind a connection to an identity.
  • Public wire constants: WIRE_VERSION, MAX_TOPIC, MAX_PAYLOAD, MAX_FRAME, SUMMARY_BYTES, ACK_IDS_CAP, MAX_SIG and BLOOM_K.
  • tines_core::store_conformance, behind the conformance feature and std-free. A suite that lets any backend prove the §4 Store contract. MemStore and LogStore both run it.
  • The conformance feature also enables a #[doc(hidden)] conformance module of frame encoders and local limits for the fuzz harness. It isn't part of the supported API.

Changed

  • Addressed delivery is a custody chain. It used to be epidemic forwarding.
    • A message has one custodian at a time, which hands it on and releases it when the next holder acks.
    • A lost ack can leave two custodians for a while; the protocol never drops a message.
    • Bounce records carry a direction, so only a record of an actual send validates an ack.
  • Core never trusts a backend's filtering. It re-checks dest and is_released on every custody candidate. It re-checks dest when building a summary, rebuilding a broadcast diff, and sending a broadcast.
  • Core hardening (spec §10):
    • F6: a peer's NodeId is pinned by its first frame in a contact. A later frame with another from is dropped and counted as IdentityChanged. The pin resets on Lost, and on a repeat Contact for a known peer.
    • F8: a peer with no inbound frame for PEER_IDLE_MS (30 s) is evicted as if Lost. That includes a peer that only ever sent Contact. Idle peers are swept before a peer is added and at the start of every next_frame, and a peer that resumes later is a new implicit contact.
    • F9: at most one received Summary per peer is acted on per SUMMARY_INTERVAL_MS. A summary is accepted once at least half the interval has passed, to allow for poll jitter (spec decision L). The send-queue rebuild skips ids the peer's filter says it holds (Bloom::might_contain).
    • F13:
      • the event queue drops the oldest event when full;
      • a removed peer's pending acks are purged, and only that peer's;
      • frames, and reassembled observations, must be canonical.
  • Receipts are scoped to the contact. Broadcast-send records for an address are dropped when its contact starts or ends. A broadcast whose send fails has its record undone, so its ack isn't accepted.

Fixed

  • A receiver whose store returned Err (for example Full) still acked, so the sender released custody of a message nobody kept. Now nothing is acked unless it was stored, addressed or broadcast (F1).
  • From the 1.0 review:
    • Silent and contact-only peers were never evicted, so eight spoofed addresses could still lock out every newcomer (S1, F8).
    • The drop table never freed its keys. After 32 distinct (addr, reason) pairs, every drop went to the overflow slot until reboot (S7).
    • Losing one peer purged every peer's pending acks (S9).
    • Broadcast receipts weren't limited to this contact, and a failed send could still be confirmed (S10).
    • A store whose broadcast iterator yielded addressed ids could leak them into a summary or forward them as broadcasts (S11). A test store that overrides no filtering now pins the re-checks (B8).
    • A repeat Contact didn't reset the F6 pin.
    • A reassembled observation was decoded with trailing bytes allowed.
    • The summary rate limit could ignore an on-time summary measured a millisecond early.
    • DropReason::IdentityChanged carried a variant-level #[non_exhaustive], so code outside the crate couldn't match it.

Hosts 0.4.0 — Unreleased

tines, tines-store, tines-bt, tines-ros2 and tines-sim move to 0.4.0 and depend on tines-core = "1". Like tines-core 1.0.0, nothing here is tagged or published yet.

The 0.3.0 release shipped only tines-core and tines-sim. The four other host crates were added afterwards, during the fleet milestone, and carried the workspace version 0.3.0 in the tree without being released. The breaks below are measured against that in-tree 0.3.0. This release also carries the fleet milestone's host work (F2–F5, F17).

Breaking

  • Log format v2 (tines-store).
    • The file starts with a "TINESLOG" header and a format version (now 2, for tines-core 1.0's Observation encoding).
    • Each record has a sync marker, a length and a CRC-32C, and ids are recomputed on load.
    • Opening a log of any other version is an error that names the fix (delete the log). There's no migration.
  • LogStore::flush is the Store::flush(now) trait method (tines-store). The inherent flush() is gone. Call Node::flush(now); the marker now uses the caller's tick.
  • LogStore::open(path, now) quarantines what it loads (tines-store). Observations already in the log are held back until the host feeds trusted wall time (see Added).
  • tines::serve takes a fifth argument, peer_time_window_ms. tines::handle takes observed_at: Option<u64> before now.
  • Service config (tines):
    • unknown keys are rejected (deny_unknown_fields);
    • trusted_clock accepts only "system"; any other value is a config error;
    • the first_seq field is gone (seqs come from the store's boot counter).

Added

  • Trusted wall time in the hosts (spec §9, decisions G and J). A host opens its store before it knows the time, then adopts a wall time once per run.
    • Local source first. The service's trusted_clock = "system", or tines-ros2's trust_system_clock / trust_wall_clock.
    • Otherwise a peer, by decision J.
      • The host collects Node::peer_time_estimate for peer_time_window_ms (default 10 000) from the first plausible estimate.
      • It then adopts the latest estimate.
      • The store only offers estimates within its LogStoreBuilder::plausible_wall_ms range, so a forged one can't hide the honest ones.
    • Plausible range. A wall time is adopted only if it's between 2026-01-01Z (tines::trust::WALL_FLOOR_MS) and about 2126 (WALL_CEILING_MS). That applies to the system clock and to peers.
    • Flush on trust. Adopting the time flushes the store at once, so a marker carrying the wall time reaches disk.
    • Until trust, observations carry observed_at: None; after it, the host's wall time.
  • tines::trust: WALL_FLOOR_MS, WALL_CEILING_MS, PLAUSIBLE_WALL_MS, DEFAULT_PEER_TIME_WINDOW_MS, is_plausible_wall_ms, system_wall_ms, adopt_wall_clock and PeerTimeWindow. HostClock gains anchor_wall, wall_ms and is_trusted.
  • Service config keys (tines):
    • trusted_clock;
    • peer_time_window_ms (default 10000);
    • unknown_downtime_age_ms (default: the LogStore default, 24 h).
  • TinesNode trust methods (tines-ros2): trust_system_clock, trust_wall_clock, set_peer_time_window_ms and has_trusted_time. They give a ROS2-only fleet a local trusted-time source.
  • LogStore quarantine and fold (tines-store).
    • LogStore implements WallClockStore.
    • set_wall_clock folds the downtime into held ages, once, and returns a FoldReport, which fold_report reads back later.
    • Downtime known: held ages gain the wall-clock downtime.
    • Downtime unknown: held addressed observations expire, and held broadcasts are floored at unknown_downtime_age_ms.
    • Quarantined entries are exempt from every trim rule (spec decision L).
    • peer_time_estimate returns the latest estimate among observations stored since this open.
  • LogStoreBuilder (tines-store):
    • set_unknown_downtime_age_ms (default 24 h);
    • plausible_wall_ms (default unbounded; both hosts pass tines::trust::PLAUSIBLE_WALL_MS).
  • Snapshots (tines-store, spec decision I).
    • open, after its tick fold, rewrites the log as a snapshot. So does set_wall_clock, after its fold. A snapshot holds every live observation at its current age, a Release for every released id still held, and a closing clock marker.
    • The snapshot goes to <log>.snapshot-tmp in the same directory. That file is fsynced, locked, renamed over the log, and then the directory is fsynced. A leftover temp file is removed on open.
    • The fold's results and age lineage then survive any number of reboots. Each snapshot also compacts the log.
  • New host crates (first released in this line, see above):
    • tines-store: LogStore, a persistent backend;
    • tines-bt: a BLE transport over BlueZ;
    • tines: a standalone service with Unix-socket JSON IPC;
    • tines-ros2: a ROS2 node, with rclrs behind the ros2 feature.

Changed

  • LogStore persists a boot counter, and first_seq() returns boot_count << 32. Both hosts seed Node::new from it.
  • LogStore::release writes a Release record immediately.
  • LogStore::open takes an exclusive lock and creates the file 0600.
  • LogStore::flush and set_wall_clock clamp an earlier now to the store's time anchor, so stored_at and clock markers never move backwards.
  • A log whose first header was torn while it was being created (shorter than the header, starting with the magic) opens as a new log. Any other short file is still refused.
  • tines-bt rewrite:
    • L2CAP SeqPacket channels, one frame per packet.
    • Every node advertises and scans; the smaller NodeId dials and the larger accepts.
    • Connections are bound to the advertised NodeId, and frames whose claimed sender differs (tines_core::frame_sender) are dropped.
    • Connections emit Contact and Lost.
    • Live connections are capped by tines-bt's own MAX_PEERS (8).
  • tines-sim's property tests run 64 cases again, and the bounce-overflow property checks that the table really overflowed.

Fixed

  • The standalone service never polled its Node, so it never exchanged anything. It now runs the exchange on a poll loop (F2).
  • tines-bt had no listener, lost frame boundaries on a stream socket, and stamped every frame with the configured peer (F3).
  • Sequence numbers repeated after every restart, and released flags were never persisted (F4).
  • One corrupt record in the middle of the log truncated everything after it. Recovery now skips bad records and truncates only a torn tail, with an fsync of the file and its directory (F5).
  • The service's tick was wall-clock seconds. Both hosts now use one monotonic millisecond clock (F17).
  • From the 1.0 review:
    • LogStore didn't implement Store::flush, so Node::flush wrote no marker and fsynced nothing (B1).
    • Fold results and age lineage lived only in memory. An expired message came back after the next reboot, and ages reset on a third boot (B2). Snapshots fix both.
    • A forged observed_at = u64::MAX overflowed: a panic in debug, a time near 1970 in release (B3). The arithmetic saturates, and the hosts bound every wall time they adopt.
    • A flush between open and the fold made the fold count that time twice (S2).
    • flush didn't clamp an earlier now (S3).
    • tines-ros2 had no local trusted-time source (S4).
    • A quiet reboot lost everything, because nothing flushed after trust was set (S5).
    • The service trusted a 1970 system clock and ignored config typos (S6).

Known issues

  • The service talks to exactly one configured peer. The multi-peer allowlist the fleet-fixes spec describes isn't wired up (F3).
  • Neither host flushes on a timer or at shutdown (F18).
    • The service trims and flushes only after 64 poll rounds that each stored something new.
    • tines-ros2 flushes only when it adopts trusted time, and never trims.
  • Peer time is unauthenticated until 1.1. An adopted time isn't corrected while the host runs (F19).

0.3.0 — 2026-10-18

Breaking

  • Store now requires release(&mut self, id) and is_released(&self, id) -> bool. Implementations of Store — including test doubles and future backends — must provide them.

Added

  • Store::release marks an observation as sent, so it is no longer offered for exchange. MemStore tracks a per-entry released flag, and its for_each_addressed_id skips released ids as an optimization (the exchange predicate remains the load-bearing check). Releasing an id that is not stored is a no-op.

0.2.1 — 2026-10-18

Changed

  • Package metadata only: repository, documentation, readme, keywords and categories, shared through [workspace.package]. The README now appears on crates.io.
  • Each crate now ships the Apache-2.0 LICENSE text.

0.2.0 — 2026-10-18

Hardening from a full-codebase review. Wire-compatible with 0.1.0 except where noted.

Added

  • Exchange::on_send_error: rolls back the last next_frame after a failed send. Node::poll calls it, so a Busy or Unreachable frame is retried instead of being lost.
  • Store::for_each_addressed_id, with a default that visits every id. MemStore overrides it, so custody no longer reads every broadcast observation on each poll.
  • Bloom::fold, and Bloom::from_parts now accepts any power-of-two length up to SUMMARY_BYTES. Summaries are folded to fit the transport's MTU; a 0.1.0 node rejects a folded summary, so mixed-version links only sync when the MTU allows a full 512-byte filter.
  • InFlight, describing a fragment stream in progress.

Changed

  • Breaking: Peer::in_flight is now Option<InFlight> (was Option<(ObservationId, u8)>).
  • Custody (addressed) and broadcast sending alternate per peer, and addressed candidates are taken in rotation.
  • Bounce suppression lasts one contact, as the addressed-delivery spec says.

Fixed

  • A fragment stream could panic, or send corrupt fragments, when the observation's age changed encoded length mid-stream or the tick went backwards.
  • Summaries larger than the MTU stopped all broadcast sync on narrow links.
  • A full bounce table silently stopped recording, so one addressed message was re-sent on every poll and starved broadcast.
  • An addressed send interrupted another peer's fragment stream, which was then never finished.
  • An addressed message that couldn't fit the MTU blocked every other addressed message for that peer.
  • A duplicate addressed message was forwarded back to the carrier that sent it.

CI

  • Tests, clippy and docs also run without the alloc feature; doc warnings fail the build; --locked everywhere; toolchain pinned to 1.99.0; actions pinned to commit SHAs.

0.1.0

Initial release: tines-core and tines-sim.