21 KiB
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 — 2026-10-22
Implemented on feat/tines-core-1-0. 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_VERSIONbyte (4). A 0.x-shaped frame is dropped and counted asDropReason::VersionMismatch. - The new
Ackframe is the lastFramevariant (see Added). - Frames must be canonical. Trailing bytes after a complete frame, or after
a reassembled observation, are rejected and counted as
Malformed.
- Every frame now starts with a
Destreplacesto/lifetime_ms.- An addressed observation carries
dest: Option<Dest>, whereDest { to, lifetime_ms }. - The identity hash covers
dest.to. It still excludeslifetime_ms,age_msandobserved_at.
- An addressed observation carries
- Signature slot.
Observationcarriessig: Option<Signature>(version,SigAlgo, and up toMAX_SIG= 64 bytes).- 1.0 checks the wire shape and carries signatures verbatim. It neither
signs nor verifies; that's 1.1.
sigis outside the identity hash.
- Nine items are now
pub(crate):Exchange(and with itExchange::on_send_error; drive the exchange throughNode);Peer;InFlight;Bloom(and its publicsaltfield);Reassembler;fragment;Frame;WireError;FRAG_OVERHEAD.
- The
bloom,exchange,fragandwiremodules are private, so paths through them are gone:tines_core::wire::frame_senderis nowtines_core::frame_sender;DropReasonandNodeEventare 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_CAPandACK_BUFFER_CAPare no longer intines_core::consts;- the new local constants,
PEER_IDLE_MS,DROP_TABLE_CAPandMIN_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
ObservationwithObservation::broadcastorObservation::addressed, and aTrimPolicywithTrimPolicy::default()plus field assignment.
- Enums (match with a wildcard):
Node::drain_eventsreturnsNodeEvent<T::Addr>. This replaces the fleet milestone's unreleasedConfidenceEvent.Node::store_mutis removed. It was added by the unreleased fleet fixes so the service could reachLogStore::flush. UseNode::flush, andNode::set_wall_clock/Node::peer_time_estimatefor aWallClockStore. Read access throughNode::storestays.- MSRV.
rust-version = "1.82.0". CI builds tines-core on 1.82, with and without default features and forthumbv7em-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
Ackframe carries up toACK_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 }fromNode::drain_events.
- An ack for an addressed observation moves custody: the sender releases
its copy (
- Counted drop reporting. A frame this node can't use isn't dropped
silently.
- The exchange counts it under a
DropReason:VersionMismatch,Malformed, orIdentityChanged(F6). The count is keyed by the sender's transport address. Node::drain_eventsreports each counter asNodeEvent::Dropped { addr, reason, count }, after the queuedDeliveredevents.- The counter table is bounded. When it's full, further drops go to one
overflow counter, reported with
addr: Noneand 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.
- The exchange counts it under a
Store::flush(now) -> Result<(), StoreError>andStore::is_empty, both with safe defaults, plusNode::flush(now).flushis the store's durability point and its time anchor for ages after a restart. Flushing twice is harmless, and an earliernowis clamped. Core never flushes on its own.WallClockStore(spec decision H). An opt-in supertrait ofStore, for backends that hold data across power-off. It has an associatedFoldReport,set_wall_clock(wall_now, now)andpeer_time_estimate(now).LogStoreimplements it;MemStoredoesn't.Node::set_wall_clockandNode::peer_time_estimate. They exist whenS: WallClockStoreand delegate to it, so no&mut Storeescapes the facade. Core forwards the wall time and never reads it.Dest,SignatureandSigAlgo, and the constructorsObservation::broadcast,Observation::addressedandObservation::with_observed_at. The two constructors panic on an oversized topic or payload;Node::observeandNode::observe_toreturnObserveError::TooLargeinstead.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_SIGandBLOOM_K. tines_core::store_conformance, behind theconformancefeature andstd-free. A suite that lets any backend prove the §4Storecontract.MemStoreandLogStoreboth run it.- The
conformancefeature also enables a#[doc(hidden)]conformancemodule 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
destandis_releasedon every custody candidate. It re-checksdestwhen building a summary, rebuilding a broadcast diff, and sending a broadcast. - Core hardening (spec §10):
- F6: a peer's
NodeIdis pinned by its first frame in a contact. A later frame with anotherfromis dropped and counted asIdentityChanged. The pin resets onLost, and on a repeatContactfor a known peer. - F8: a peer with no inbound frame for
PEER_IDLE_MS(30 s) is evicted as ifLost. That includes a peer that only ever sentContact. Idle peers are swept before a peer is added and at the start of everynext_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.
- F6: a peer's
- 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 exampleFull) 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
Contactdidn'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::IdentityChangedcarried 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'sObservationencoding). - 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.
- The file starts with a
LogStore::flushis theStore::flush(now)trait method (tines-store). The inherentflush()is gone. CallNode::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::servetakes a fifth argument,peer_time_window_ms.tines::handletakesobserved_at: Option<u64>beforenow.- Service config (tines):
- unknown keys are rejected (
deny_unknown_fields); trusted_clockaccepts only"system"; any other value is a config error;- the
first_seqfield is gone (seqs come from the store's boot counter).
- unknown keys are rejected (
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'strust_system_clock/trust_wall_clock. - Otherwise a peer, by decision J.
- The host collects
Node::peer_time_estimateforpeer_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_msrange, so a forged one can't hide the honest ones.
- The host collects
- 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.
- Local source first. The service's
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_clockandPeerTimeWindow.HostClockgainsanchor_wall,wall_msandis_trusted.- Service config keys (tines):
trusted_clock;peer_time_window_ms(default 10000);unknown_downtime_age_ms(default: the LogStore default, 24 h).
TinesNodetrust methods (tines-ros2):trust_system_clock,trust_wall_clock,set_peer_time_window_msandhas_trusted_time. They give a ROS2-only fleet a local trusted-time source.- LogStore quarantine and fold (tines-store).
LogStoreimplementsWallClockStore.set_wall_clockfolds the downtime into held ages, once, and returns aFoldReport, whichfold_reportreads 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_estimatereturns 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 passtines::trust::PLAUSIBLE_WALL_MS).
- Snapshots (tines-store, spec decision I).
open, after its tick fold, rewrites the log as a snapshot. So doesset_wall_clock, after its fold. A snapshot holds every live observation at its current age, aReleasefor every released id still held, and a closing clock marker.- The snapshot goes to
<log>.snapshot-tmpin 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 theros2feature.
Changed
LogStorepersists a boot counter, andfirst_seq()returnsboot_count << 32. Both hosts seedNode::newfrom it.LogStore::releasewrites aReleaserecord immediately.LogStore::opentakes an exclusive lock and creates the file0600.LogStore::flushandset_wall_clockclamp an earliernowto the store's time anchor, sostored_atand 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
NodeIddials 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
ContactandLost. - 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:
LogStoredidn't implementStore::flush, soNode::flushwrote 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::MAXoverflowed: 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).
flushdidn't clamp an earliernow(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
Storenow requiresrelease(&mut self, id)andis_released(&self, id) -> bool. Implementations ofStore— including test doubles and future backends — must provide them.
Added
Store::releasemarks an observation as sent, so it is no longer offered for exchange.MemStoretracks a per-entryreleasedflag, and itsfor_each_addressed_idskips 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,keywordsandcategories, shared through[workspace.package]. The README now appears on crates.io. - Each crate now ships the Apache-2.0
LICENSEtext.
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 lastnext_frameafter a failed send.Node::pollcalls it, so aBusyorUnreachableframe is retried instead of being lost.Store::for_each_addressed_id, with a default that visits every id.MemStoreoverrides it, so custody no longer reads every broadcast observation on each poll.Bloom::fold, andBloom::from_partsnow accepts any power-of-two length up toSUMMARY_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_flightis nowOption<InFlight>(wasOption<(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
allocfeature; doc warnings fail the build;--lockedeverywhere; toolchain pinned to 1.99.0; actions pinned to commit SHAs.
0.1.0
Initial release: tines-core and tines-sim.