Fuzz steps can move the clock backwards, while a peer's idle time counts
from its last frame, so advancing from the current tick could stay short
of the idle window. Track the latest tick and probe from it; skip the
probe when the tick is too close to u64::MAX to advance. A 5-minute
nightly run (84k inputs) is clean after the fix.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Also match DropReason::IdentityChanged as a unit variant in the harness,
since the variant-level non_exhaustive is gone.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The fuzz oracle and its proptest twin checked much less than §7 claims
(S8). The harness now:
- records every frame the node attempts to send, before the transport
can refuse it, so the "every frame fits mtu()" check can fail;
- reads the MTU from its own u16 (input bytes 1-2), mapped into
MIN_MTU..=MAX_FRAME, instead of reusing the first step tag;
- reconciles drained drop counts per address: VersionMismatch/Malformed
drops equal the frames that fail decode (the overflow slot makes up
any difference), IdentityChanged drops never exceed frames whose
`from` changed, and nothing is counted beyond the frames received;
- checks no addressed observation is stored, or forwarded in an Obs
frame, past its lifetime;
- checks signatures round-trip from the input (an observation stored from
a whole Obs frame keeps exactly its signature, and a stored signature
never changes), replacing the input-independent sig_round_trip call;
- checks every acked id is stored (F1) and bounds the broadcast ids
offered per received summary by SEND_QUEUE_CAP (F9);
- has a real adversarial Mode (input byte 0): well-formed Obs, Frag,
Summary and Ack frames from 3 x MAX_PEERS addresses, with signed and
max-size observations (u16 payload lengths), chosen observers
(including the node's own id), ages, lifetimes and addressing,
structured fragment streams that conflict, repeat or arrive out of
order, replayed summaries, `from` values that change within a contact,
and Acks from the peer's real id naming ids the node really sent;
- adds the §10 peer-table recovery probe, which the fuzz target runs
after every adversarial input.
fuzz_twin.rs now runs the fuzz entry point on random bytes, generated
adversarial steps, and two rewritten properties that were trivially
true: summary_repeat_does_not_resend seeds broadcasts and checks a
replay re-sends nothing while the next on-time summary re-sends all;
peer_table_recovers_after_flood floods the table, checks it is full,
and probes recovery after PEER_IDLE_MS. The recovery tests are
#[ignore]d until the S1 idle sweep lands, and the fuzz target fails on
the first adversarial input until then. A 180 s nightly run with the
probe disabled found nothing else.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
tests/fuzz_twin.rs includes ../../fuzz/src/lib.rs, which the tines-core
package doesn't ship, so the published source carried a test that can't
build (S13, packaging). `cargo package --list` now omits it and the
packaged crate verifies; tests/vectors.rs still ships, since src/lib.rs
includes it.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
rust-toolchain.toml overrides `rustup default nightly`, so the scheduled
job ran cargo-fuzz on stable and its -Z flags failed (B7). Every cargo
call now names +nightly. Also:
- upload fuzz/artifacts/node/ when the run fails;
- drop the false claim that the corpus "grows in place across
schedules": new inputs live only in a 7-day artifact until someone
minimises and commits them;
- pin actions by SHA as ci.yml does (checkout v4.4.0, upload-artifact
v4.6.2), and install cargo-fuzz with --locked.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The msrv job ran `cargo test` on 1.82, which can't parse a proptest
dev-dependency that needs edition 2024 (B5). The MSRV promise covers
building tines-core, so the job now builds it on 1.82 with default
features, with --no-default-features, and for thumbv7em-none-eabihf.
`cargo +1.82.0` makes the toolchain explicit over rust-toolchain.toml.
All three builds pass locally on 1.82.0.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
fuzz/Cargo.toml still required tines-core 0.3.0, so since the 1.0.0
version bump `cargo fuzz run node` failed version selection (B6). The
requirement is now "1" and the lock is regenerated.
scripts/ci.sh now runs rustfmt and clippy (all targets, -D warnings) on
the out-of-workspace fuzz crate on the pinned stable toolchain, so it
can't silently stop building again. fuzz/.gitignore ignores target,
artifacts and coverage.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
60337d6 cut the shared props config from 64 to 16 cases for every
property to keep bounce_overflow_keeps_a_holder cheap (S12). The shared
block is back at 64; the bounce-overflow property gets its own 16-case
block, and now asserts that more than SEND_QUEUE_CAP ids left node 0
within one contact, so the table really overflowed.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
public_api.rs imported only spec §3's original list (S15). It now also
probes WallClockStore, Node::set_wall_clock, Node::peer_time_estimate and
Node::flush (decision H), the Node method signatures, the NodeEvent shape
and the public wire constants, and names nothing §3 makes private
(Frame, WireError, local constants).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Decision K (spec §5): the local capacities and timings are defaults that
will become configuration, so they are `pub(crate)`. The wire constants
stay public.
- MAX_PEERS, SUMMARY_INTERVAL_MS, REASSEMBLY_TIMEOUT_MS, SEND_QUEUE_CAP,
MIN_MTU, EVENT_QUEUE_CAP, DROP_TABLE_CAP, BCAST_SENT_CAP, ACK_BUFFER_CAP
and PEER_IDLE_MS are now `pub(crate)`; a compile_fail doctest pins it.
- MIN_MTU's doc claimed it was the floor below which a fragment has no
room. It is only the frag sweep's lower bound (BLE ATT's 23). The real
floors are now stated on `Transport::mtu` and pinned by
`consts::tests::mtu_floors`: 85 to carry every valid observation, 66 to
fragment at all, 35 for an Ack, 26 for a summary.
- tines-bt caps live connections with its own MAX_PEERS (8).
- tines-sim's props mirror MAX_PEERS/SEND_QUEUE_CAP, checked against core
through the hidden `conformance` module; the fuzz twin reads it there.
- node.rs: drain_events' rustdoc no longer links the now-private consts.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Spec §3 (decision D-b) lists `Frame` and `WireError` as private; lib.rs
re-exported both. Only `frame_sender` stays public.
- The pinned vectors in tests/vectors.rs now compile in-crate as a
`#[cfg(test)] #[path]` module (autotests = false keeps Cargo from also
building them as an integration test). Every golden vector now checks
`encode() == GOLDEN` as well as the decode, and new literal vectors pin a
trailing-byte rejection and an addressed Obs with a nonzero age and an
`observed_at`.
- A `#[doc(hidden)]` `conformance` module (behind the `conformance`
feature) gives the fuzz harness byte-level frame encoders/decoders and
core's local limits without exposing `Frame`. The fuzz_twin test target
now requires `conformance`, which scripts/ci.sh enables.
- compile_fail doctests pin that `Frame`/`WireError` are unreachable.
- Remove the THROWAWAY gen_missing_golden_vectors test and fix stale
wire.rs comments about the version/tag byte offsets.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Both hosts open LogStore with plausible_wall_ms(PLAUSIBLE_WALL_MS), so a
forged out-of-range peer estimate is dropped before the store picks the
latest and can't hide an honest one (decision J, review B3). Un-ignores
latest_estimate_wins_over_smallest_age now that the store returns the
latest estimate, and adds forged_estimate_does_not_hide_an_honest_one.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Spec §9 decision J: an estimate outside the plausibility bounds doesn't
count. A max-based peer_time_estimate always returned a forged
observed_at = u64::MAX, which the host then rejected, so it never saw
the honest estimates and stayed quarantined.
LogStoreBuilder::plausible_wall_ms(floor..=ceiling) sets the range
(default unbounded, so behaviour is unchanged when unset), and
peer_time_estimate filters candidates against it before taking the
max. The WallClockStore signature is unchanged.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- Store::flush and Node::flush: the §4 contract (durability point, time
anchor, harmless twice, earlier now clamped, Err(Backend) leaves the
store usable, application decides when). No "infallible" or
"duplicate custodian" wording.
- WallClockStore and Node::set_wall_clock / peer_time_estimate: define
quarantine generically, a second set_wall_clock is a no-op returning
the first report, peer time is unauthenticated until 1.1.
- Observation::age_ms: a lower bound that every holder advances; use
observed_at for absolute time.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
idempotent_flush now also checks that an earlier `now` is clamped (no
age moves), that a flush never ages an observation inserted after its
`now`, that a release survives a flush, and that the store accepts
inserts afterwards. Durability itself needs a restart, which the
std-free suite can't do; tines-store tests it through the trait.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Review findings B1, B2, S2, S3, B3 (store sites), decision I, J and L.
- B1: the clock-marker flush is now the `Store::flush` impl, so
`Node::flush` writes the marker and fsyncs. The inherent
`LogStore::flush` is gone (calls resolve to the trait method).
- B2 / decision I: `open` (after the tick fold) and `set_wall_clock`
(after the wall fold) rewrite the log as a snapshot: header, Boot,
every live Obs at its current age with stored_at = now, a Release
for every released id still held, and a closing Clock marker (no
wall at open, wall_now after the fold). Temp file beside the log,
fsync, rename, fsync dir. Every stored_at on disk is then in the
current boot's ticks, so lineage survives any number of reboots, and
an expiry or floor from the fold survives a later reboot. A leftover
temp file from an interrupted snapshot is removed on open.
- Lock across the rename: the snapshot's temp file is created 0600 and
locked before it is renamed over the log, so the file at the path is
always locked by the live store. An opener that locked a file a
snapshot just replaced detects it (dev/inode check) and retries.
- S2: the known-downtime fold adds downtime minus the ticks the open
fold counted minus (now - open_tick), once, not per-entry
now - stored_at, so a flush before trust no longer double-counts.
- S3: flush and set_wall_clock clamp `now` to the time anchor (the
latest tick the store has seen); stored_at never moves backwards.
- B3: the derived marker wall and the expiry `lifetime + 1` saturate.
- Decision J: peer_time_estimate returns the latest estimate among
non-quarantined observations with observed_at; rustdoc says peer
time is unauthenticated.
- Decision L: trim rustdoc states quarantined entries are exempt from
every rule; tested.
- A header torn while the log was first created (shorter than the
header, starting with the magic) opens as a new log; any other short
file is still refused and left alone.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The host crates ship breaking changes (log format v2, flush and
set_wall_clock, the tines::serve signature, tines-core 1) that can't go
out as the already-released 0.3.0 (1.0 review S13). tines-core stays
1.0.0.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- S4: TinesNode::trust_system_clock(now) and trust_wall_clock(wall, now)
give a ROS2-only fleet a local trusted-time source, with the same
plausibility bounds as the service (tines::trust).
- Decision J: poll collects peer estimates for a window (default 10 s,
set_peer_time_window_ms) from the first plausible one and adopts the
latest, once; implausible estimates (B3) are never adopted.
- S5: adoption goes through tines::trust::adopt_wall_clock, which flushes
the store right after set_wall_clock.
- has_trusted_time() reports whether a wall time was adopted.
The `ros2` feature is outside the default gate; the shared logic is
tested in `tines`, and these methods were type-checked against it.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- B3: HostClock::wall_ms saturates. New `tines::trust` module: wall times
outside WALL_FLOOR_MS (2026-01-01Z) ..= WALL_CEILING_MS (+100 years) are
never adopted, from a peer or the system clock.
- Decision J: `serve` takes `peer_time_window_ms`; PeerTimeWindow opens on
the first plausible estimate, keeps the latest (advanced by ticks), and
adopts once when the window closes. The scan stops once trusted.
- S5: adopt_wall_clock flushes right after set_wall_clock.
- S6: `trusted_clock` accepts only "system", Config denies unknown fields,
an implausible system clock is logged and left to the peer path, and the
docs say to order the unit after time-sync.target. New config key
`peer_time_window_ms` (default 10000).
- Tests: the observed_at and time_from_peer tests now run the host's own
logic through `serve`; smallest_age_wins is replaced by window and
latest-estimate tests (the latter ignored until the store returns the
latest estimate); forged u64::MAX and below-floor estimates aren't
adopted and don't panic the host.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- The drop table now empties on drain, keys included. Before, after 32
distinct (addr, reason) pairs every later drop went to the overflow
slot until reboot. The overflow slot's reason is documented as that of
the first drop to overflow since the last drain (review S7).
- F9's receive-side check accepts a summary at least
SUMMARY_INTERVAL_MS / 2 after the last one acted on, so an on-time
summary measured at 1999 ms isn't ignored (decision L, review D-f).
- DropReason::IdentityChanged loses its variant-level non_exhaustive,
which made it unmatchable outside the crate; the enum keeps its own.
- Stale comments: drain_events is used by Node (drop the dead_code
allow), and NodeEvent drops are this node's, not the peer's.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The reassembler decoded with postcard::from_bytes, which accepts
trailing bytes. It now uses take_from_bytes and rejects a non-empty
remainder, as Frame::decode does (spec §10 F13).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The idle check ran only in on_frame and only for the peer that just
sent, so a peer that went quiet, or never sent (contact-only), held its
slot forever: eight spoofed addresses locked out every newcomer
(review S1).
Peers now record when their contact started (added_at), and idle peers
(now - last_inbound.unwrap_or(added_at) > PEER_IDLE_MS) are swept as if
Lost before a peer is added and at the top of next_frame. A peer that
resumes after its idle window is an implicit new contact: its frame is
processed rather than silently dropped. A repeat Contact restarts the
idle clock.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- remove_peer purges only the removed peer's pending acks, not every
peer's (review S9, F13).
- bcast_sent records for an address are dropped when its contact starts
or ends, and a broadcast whose send fails has its record undone, so a
broadcast Ack is accepted only for this contact's dispatched frames
(review S10).
- A repeat Contact for a known peer starts a new contact: it resets the
F6 pin and the per-contact bounce and receipt records (spec §10 F6).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Core re-checks dest when building the summary Bloom filter, rebuilding
a peer's broadcast send queue, and emitting a broadcast, so a store
whose for_each_broadcast_id yields addressed ids can't leak them into a
summary or forward them (expired) as broadcasts (review S11).
Adds UnfilteredStore, a test store whose iterators yield every held id,
released and broadcast ones included, pinning spec §4's 'core never
trusts a backend's filtering' for custody, acks and summaries (B8).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
I: LogStore snapshots the log at the end of open and after the
set_wall_clock fold, so fold results and age lineage survive reboots.
J: hosts adopt the latest peer time estimate within a short window,
bounded, once per run.
K: local constants become pub(crate); only wire constants stay public.
L: quarantined entries are exempt from the count trims; the F9 summary
rate limit accepts at SUMMARY_INTERVAL_MS / 2; Observation.sig stays a
public field and §2's wording is corrected.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Record the public 1.0 API that 941a7ce introduced in place of the revived
store_mut backdoor: the opt-in WallClockStore trait (FoldReport,
set_wall_clock, peer_time_estimate) and Node::set_wall_clock /
Node::peer_time_estimate, bounded on S: WallClockStore. Updates §3's
public table and Node method list and §9's fold and time-from-peers
text; core still never interprets wall time.
Also split the drain_events sentence into its own §3 bullet.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Task 10b reached LogStore::set_wall_clock through a general Node::store_mut.
Kyle: replace it with a narrower accessor. A WallClockStore trait (set_wall_clock
+ peer_time_estimate, implemented only by LogStore) now backs Node::set_wall_clock
and Node::peer_time_estimate, so no &mut Store escapes the facade.
The workspace build pulls in bluer -> dbus -> libdbus-sys, which links
against the system D-Bus library. The ubuntu-latest runner has pkg-config
but not dbus-1.pc, so libdbus-sys's build script fails. Install the
libdbus-1-dev headers (matching Dockerfile.ros2) before running the gate.
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>
- F: Store::flush(now) with a no-op default; Node::flush(now) replaces
Node::store_mut. The caller's now anchors ages (fixes LogStore's stale
clock marker, F18).
- G: age_ms is documented as a lower bound. Robots are assumed to have a
trusted wall clock (RTC or GPS); LogStore adds power-off time on open
from a wall-clock flush marker. Unknown downtime expires held addressed
messages and floors held broadcasts' age at unknown_downtime_age_ms.
Hosts set observed_at.
- AGENTS.md: specs and plans change only to record decisions Kyle has
discussed and agreed.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- README: custody chain and receipts replace epidemic forwarding; Ack
frame; running on a robot (host crates, current limits); security;
crate layout, constants and reading list.
- AGENTS.md: current spec list, receipt constraints and review focus
7-8, first_seq rule, constants, host crates and the ros2 gate.
- THREAT_MODEL.md: re-checked at 89041ea. F1, F2, F5, F17 fixed; F3
(single configured peer) and F4 partly fixed; new F18 (no scheduled
flush, stale clock marker).
- CHANGELOG: Unreleased section for the fleet milestone and fixes.
- tines-bt README: single-peer limit; manual BLE check not yet recorded.
- tines service rustdoc: oversized-frame behaviour, JSON tagging, clock.
- 1.0 spec: approved (2026-10-20), including §10 and the adversarial
fuzz mode.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Exchange::accept enqueued a pending ack whenever
!(duplicate && addressed && released) held, even when the backend
store.insert returned Err (store full / backend error). On Err the
released flag computed to false, so an ack was still enqueued: a
receiver that did not keep the message acked it. For an addressed
offer that releases the sender's custody for a message nobody holds;
for a broadcast it fires a false confidence event at the sender.
Gate the ack enqueue on res.is_ok(): enqueue only when the store
actually retained the observation. The existing released-holder
exception still applies among kept copies. No bounce is recorded on
Err (already the case).
Adds a unit test exercising a capped store (StoreError::Full) for
both a broadcast and an addressed offer, and a tines-sim property
that drives custody with CappedStore-backed nodes and asserts the
"at least one holder" invariant still holds when stores fill.
Rewrite tines-ros2/src/node.rs against the rclrs 0.8 API (Context::
default_from_env -> create_basic_executor -> create_node; type-inferred
subscriptions; thiserror-based RclrsError) and switch payloads from
std_msgs String to UInt8MultiArray:
* live topics publish the raw Observation.bytes as data (no UTF-8 lossy);
* inbound callbacks use msg.data verbatim;
* receipts are a 32-byte UInt8MultiArray (peer[..16] ++ id[..16]);
* supply the required MultiArrayLayout { dim: [], data_offset: 0 }.
Bump ros_env 0.1 -> 0.3 so the crate's std_msgs pins rclrs 0.8's rosidl
runtime 0.7 (was 0.6), and update Cargo.lock. Add #[allow(dead_code)] on
ManagedTopic::subscriber (drop-guard for the rcl subscription handle).
Keep the ROS2-free build green; no changes outside tines-ros2/ (and the
Cargo.lock entry).
serve() now replies with Response::Error on a malformed JSON frame and
keeps the connection serving instead of dropping it on the parse error;
the per-connection loop only breaks on a clean disconnect
(UnexpectedEof). node.lock().unwrap() is replaced with poison-safe
into_inner() so one panicking handler can't take down the daemon.
Drop duplicate tines-core/tines-store/serde/serde_json from
dev-dependencies (only heapless is genuinely test-only).
New `tines` crate (added to workspace members) hosting a `Node<LogStore,
BtTransport>` and serving a tiny request/response IPC over a Unix-domain
socket for robots without ROS2. Four operations: `observe`, `belief`,
`events`, and `status`, over length-prefixed JSON frames.
The `serve` loop now serves each connection request-by-request (previously
it handled one request per connection, dropping the socket after the first
response). Integration tests drive `handle`/`serve` with a mock transport
that auto-acks observed broadcasts and echoes an empty Summary, so a
confidence event fires without a real BLE radio.
Adds the ninth fleet-deployment crate. Default build is ROS2-free: topic_name
hex-escapes arbitrary tines topics into valid ROS2 names under /tines/ (deterministic,
injective) and wires Node<LogStore, BtTransport>. The rclrs-backed self-contained
TinesNode lives under #[cfg(feature="ros2")] with an off-by-default ros2 feature, so
the crate builds/tests without a ROS2 toolchain. Adds scripts/check-ros2.sh (needs a
ROS2 toolchain; not run in CI), registers the crate in workspace members, and passes
sh scripts/ci.sh.
Task 8 of the fleet-deployment plan. Adds the tines-bt crate: a real
`Transport` over the `bluer` crate (D-Bus BlueZ).
- `Addr` = peer's advertised `NodeId`, so identity survives BLE
address rotation; advertising payload (flags + 16-bit service UUID +
NodeId in service data) fits legacy 31-byte EIR (`advertise()`).
- `BtTransport` drops any inbound frame whose `from` differs from the
connection's advertised NodeId (protocol violation at the transport;
core never sees a mismatch).
- Connection symmetry: the node with the numerically smaller NodeId is
the BLE central/initiator (`is_central`), independent of which side
opens the radio.
- `mtu()` reports the real L2CAP CoC MTU (falling back to the ATT MTU);
the transport does NOT split frames — core folds summaries/fragments
to `mtu()`.
- Radio I/O sits behind the narrow `Radio` trait (a `poll`-driven event
stream), so the pure logic (payload assembly, mismatch-drop, central
rule) is unit-tested with a mock radio and never needs hardware.
- `BluerRadio` drives the stack on a detached tokio task: powers the
adapter, advertises, monitors for the service UUID, opens an L2CAP
byte pipe, and surfaces inbound bytes tagged with the peer NodeId.
Adds `tests/adv.rs` (8 mock-Radio integration tests covering the 31-byte
limit, NodeId round-trip, MTU clamp, mismatch-drop, per-frame MTU, and
central rule). Added to workspace members; workspace CI (fmt, doc,
test, clippy -D warnings) is green.
LogStore::new folded `(now - last_clock)` into every entry's age_ms, but
age_ms is persisted at INSERT time and stored_at was never caught up at
flush. So an observation inserted before the last flush lost the
`last_flush - insert_time` after restart (a store that was non-idle
between insert and flush reported an age too small by that gap).
Fix by anchoring each entry's age to its own insert tick: carry
`stored_at` on the Obs record and fold `(now - stored_at)` per-entry at
load, instead of a single global fold against the last clock marker.
flush() now also advances each in-memory age to its current value so the
representation stays coherent within a run.
This is exact for idle stores and observations inserted before the last
flush. Adds tests/persistence.rs::ages_rebase_after_nonidle_flush
(insert A at tick 1000, flush at 3000, reopen at 9000 -> asserts true
age 8100, not 6100).
Add the host-side std `tines-store` crate implementing the `tines_core::Store`
trait over a durable, append-only single-file log so a robot's memory survives
reboot.
- Log format: length-prefixed `[u32 LE][postcard]` records (Obs/Trim/Meta).
- On load the index is rebuilt by replaying the log, then every age is
rebased to load time: elapsed time since the last persisted clock marker is
folded into `age_ms` and `stored_at` reset to load `now`, so ages continue
instead of freezing across a reboot.
- Persisted state: released-id set, `next_seq`, and a clock marker in a `Meta`
record; dedupe to the oldest Obs and let the last event per id win so a
re-inserted id survives a trim.
- Implements `next_seq` and `flush`; `trim` writes Trim records best-effort.
- Registered as a workspace member; std-only, `#![forbid(unsafe_code)]`.
- Adds persistence tests (append durability, rebase-on-load, released/seq
persistence). Full gate (fmt, clippy, docs, tests, no_std thumb build) is green.
Buffer pending acks (acks for addressed, bcast_acks for broadcast,
ACK_BUFFER_CAP=256, overflow drops oldest), skip a released-duplicate
offer in accept(), and emit Ack frames in next_frame after a due summary
and before addressed/broadcast traffic, draining up to ACK_IDS_CAP ids
per destination and splitting across frames to fit mtu.
Release custody on an Ack only when the Ack confirms delivery to the
intended recipient: an Ack from a peer that merely holds an
epidemic-forwarded copy no longer releases custody, so the local copy
stays available to forward toward the recipient once the only contact to
it closes.
Symlink the root Apache-2.0 LICENSE into tines-core and tines-sim so
`cargo package` includes the license text.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Share version, edition, license, authors, repository and readme through
[workspace.package]; add per-crate documentation, keywords and
categories. Fixes `cargo publish` warning about missing documentation,
homepage or repository.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Bump tines-core and tines-sim to 0.2.0 (Peer::in_flight changed type).
Add CHANGELOG.md. README gains a "Writing a store" section and a note on
calling Exchange::on_send_error when driving Exchange without Node.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
README: protocol, fragmentation, poll and addressed-delivery diagrams;
observation fields and identity; transport, belief and trim guidance;
simulator example; constants table.
AGENTS.md: drop the completed plan's task-by-task workflow and Task 14
notes, point at both specs, use scripts/ci.sh as the gate, and update
the identity-hash constraint to include `to`.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- Add Store::for_each_addressed_id (default: every id; MemStore filters)
so custody selection no longer clones every broadcast observation on
each poll.
- Factor fragment sizing into frag::plan and MAX_FRAGMENTS, used by both
frag::fragment and the exchange, and by the reassembler's bounds.
- Document `to` in the Observation::id hash input, matching the
addressed-delivery plan.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- Cut every fragment of a stream from one encoding (age frozen at the
first fragment) and bounds-check the slice: re-encoding mid-stream
corrupted fragments and could panic when the tick went backwards.
- Fold the bloom summary to fit the MTU instead of sending a ~540-byte
frame that small-MTU links reject; drop the sim's summary MTU
exemption that hid this.
- Finish a peer's in-flight stream before starting another, and count
an addressed message as delivered only after its last fragment.
- Alternate custody and broadcast per peer, rotate through addressed
candidates, and evict the oldest bounce record when full, so neither
path starves.
- Add Exchange::on_send_error; Node::poll uses it so Busy/Unreachable
sends are retried rather than silently dropped.
- Clear bounce records per contact, record duplicates against their
sender, and skip addressed messages that cannot fit the MTU.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Run clippy, docs and tests for tines-core with --no-default-features,
gating the MemStore-based tests on `alloc` so they compile there. Fail on
rustdoc warnings, pass --locked everywhere, and pin the toolchain to
1.99.0. In the workflow, use read-only permissions, cancel superseded
runs, add a 20-minute timeout, and pin actions to commit SHAs.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>