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>
This commit is contained in:
@@ -29,6 +29,7 @@ hear each other.
|
||||
- [Running on a robot](#running-on-a-robot)
|
||||
- [Crate layout](#crate-layout)
|
||||
- [Building and CI](#building-and-ci)
|
||||
- [Stability](#stability)
|
||||
- [Security](#security)
|
||||
- [Where to read more](#where-to-read-more)
|
||||
|
||||
@@ -88,14 +89,18 @@ An `Observation` is the unit of storage and exchange:
|
||||
| `seq` | the observer's sequence number | ✓ |
|
||||
| `topic` | what it is about, ≤ 64 bytes (`b"door/3"`) | ✓ |
|
||||
| `payload` | opaque bytes, ≤ 1024 | ✓ |
|
||||
| `to` | `None` for broadcast, or a recipient `NodeId` | ✓ |
|
||||
| `age_ms` | how old the observation is | |
|
||||
| `observed_at` | optional absolute timestamp, if the robot has a clock | |
|
||||
| `lifetime_ms` | validity window, present exactly when `to` is | |
|
||||
| `dest` | `None` for broadcast, or `Some(Dest { to, lifetime_ms })` for an addressed observation | `to` only |
|
||||
| `age_ms` | how old the observation is: a lower bound that every holder advances | |
|
||||
| `observed_at` | the observer's wall-clock time (ms since the Unix epoch), or `None` if it had no trusted clock | |
|
||||
| `sig` | a reserved `Signature` slot (algorithm + up to 64 bytes); carried verbatim, signed and verified from 1.1 | |
|
||||
|
||||
The **content id** is the first 16 bytes of Blake2s-256 over the ✓ fields.
|
||||
Because age and timestamps are excluded, the same fact arriving by two
|
||||
different paths, at two different ages, dedupes to one entry.
|
||||
The **content id** is the first 16 bytes of Blake2s-256 over the ✓ fields
|
||||
(`dest.to`, but not `dest.lifetime_ms`). Because age, timestamps, lifetimes
|
||||
and signatures are excluded, the same fact arriving by two different paths, at
|
||||
two different ages, dedupes to one entry.
|
||||
|
||||
`Observation` is `#[non_exhaustive]`: build one with `Observation::broadcast`
|
||||
or `Observation::addressed`, or let `Node::observe` / `Node::observe_to` do it.
|
||||
|
||||
### Time is relative
|
||||
|
||||
@@ -154,9 +159,11 @@ sequenceDiagram
|
||||
|
||||
### Wire frames
|
||||
|
||||
Frames are [postcard](https://docs.rs/postcard)-encoded and each carries the
|
||||
Every frame starts with a `WIRE_VERSION` byte (4 for wire v1), followed by
|
||||
the [postcard](https://docs.rs/postcard)-encoded frame. Each frame carries the
|
||||
sender's `NodeId`, so the core never needs to map transport addresses to
|
||||
robots.
|
||||
robots. A frame with another version byte, trailing bytes, or anything else it
|
||||
can't decode is dropped and counted (see [Receipts](#receipts)).
|
||||
|
||||
```mermaid
|
||||
classDiagram
|
||||
@@ -183,13 +190,14 @@ classDiagram
|
||||
```
|
||||
|
||||
A transport that binds a connection to a peer's identity can check a frame's
|
||||
claimed sender with `tines_core::wire::frame_sender`, without decoding the
|
||||
claimed sender with `tines_core::frame_sender`, without decoding the
|
||||
whole frame.
|
||||
|
||||
### Fragmentation
|
||||
|
||||
An observation that doesn't fit the transport's MTU is split into up to 64
|
||||
`Frag` frames (64 bytes of each frame are reserved for the header). Each peer
|
||||
`Frag` frames (65 bytes of each frame are reserved for the version byte and
|
||||
the header). Each peer
|
||||
has one stream in flight at a time, and it is finished before anything else
|
||||
starts for that peer. All fragments of a stream are cut from one encoding, so
|
||||
the receiver can always reassemble them. A partial observation is never
|
||||
@@ -295,12 +303,15 @@ it has already released, since that would hand custody back to a robot that
|
||||
no longer carries the message.
|
||||
|
||||
Broadcast confirmations surface as `NodeEvent<A>::Delivered { peer, id }` from
|
||||
`Node::drain_events`. Frames the peer could not use are counted under a
|
||||
`DropReason` (`VersionMismatch` or `Malformed`) and surface as
|
||||
`NodeEvent::Dropped { reason, count }` (see the `drop` reporting in the `tines`
|
||||
service and the ROS2 node). The event queue is bounded (64) plus a small
|
||||
counted-drop table, so drops are always reported even under a chatty peer —
|
||||
events are telemetry, and losing one never loses data.
|
||||
`Node::drain_events`. Frames this node received but couldn't use are counted
|
||||
under a `DropReason` (`VersionMismatch`, `Malformed`, or `IdentityChanged` for
|
||||
a peer whose `from` changed mid-contact) and surface as
|
||||
`NodeEvent::Dropped { addr, reason, count }`, keyed by the sender's transport
|
||||
address (see the `drop` reporting in the `tines` service and the ROS2 node).
|
||||
The event queue is bounded (64, dropping the oldest), and drops go to a small
|
||||
counted table with one overflow counter (`addr: None`), so drops are always
|
||||
reported even under a chatty peer. Events are telemetry: losing one never
|
||||
loses data.
|
||||
|
||||
## Usage
|
||||
|
||||
@@ -347,6 +358,7 @@ for event in node.drain_events() {
|
||||
tines_core::NodeEvent::Dropped { addr, reason, count } => {
|
||||
println!("dropped {count} frame(s) from {addr:?}: {reason:?}");
|
||||
}
|
||||
_ => {} // NodeEvent is #[non_exhaustive]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -407,10 +419,11 @@ pub trait Transport {
|
||||
- **Report back-pressure honestly.** Return `SendError::Busy` when the radio
|
||||
can't take a frame right now; the exchange will offer the same frame again
|
||||
on the next poll. `TooLarge` tells the exchange to give that frame up.
|
||||
- **Driving `Exchange` without `Node`?** `Exchange::next_frame` assumes the
|
||||
frame it returns gets sent. If `send` fails, call
|
||||
`Exchange::on_send_error(&err)` before the next `next_frame`, or the frame
|
||||
is treated as delivered. `Node::poll` does this for you.
|
||||
- **Mind the MTU.** `mtu()` must be at most 1500. Core can carry every valid
|
||||
observation from 85 bytes up; below that it never sends an oversized frame,
|
||||
but it sends less (see `Transport::mtu`'s docs for each floor).
|
||||
- **Binding identities?** A transport that binds a connection to a `NodeId`
|
||||
can check each frame's claimed sender with `tines_core::frame_sender`.
|
||||
|
||||
## Writing a store
|
||||
|
||||
@@ -424,12 +437,14 @@ flash or SQLite backend implements the `Store` trait:
|
||||
| `get` | the observation with `age_ms` advanced by `now − stored_at`, saturating if `now` is earlier |
|
||||
| `len` | number of stored observations |
|
||||
| `for_each_id` | every stored id, any order |
|
||||
| `for_each_broadcast_id` | ids with `to == None`; these feed the summary |
|
||||
| `for_each_addressed_id` | ids with `to == Some(_)`; scanned for custody on every poll |
|
||||
| `for_each_broadcast_id` | ids with `dest == None`; these feed the summary |
|
||||
| `for_each_addressed_id` | ids with `dest == Some(_)`; scanned for custody on every poll |
|
||||
| `release` | mark an id as handed off, so it's never offered again; a no-op if the id isn't stored |
|
||||
| `is_released` | whether an id has been released; a re-inserted observation starts un-released |
|
||||
| `for_each_topic` | age-adjusted observations whose topic starts with a prefix |
|
||||
| `trim` | apply a `TrimPolicy` (per-topic, then age, then count); return how many were removed |
|
||||
| `is_empty` | `len() == 0` (default provided) |
|
||||
| `flush` | the durability point and the time anchor for ages after a restart; harmless twice, an earlier `now` is clamped; returns `Err(StoreError::Backend)` on failure and leaves the store usable (default: no-op `Ok(())`) |
|
||||
|
||||
The store must remember when each observation was inserted (`stored_at`, in
|
||||
the caller's `Tick`s) so it can report current ages. `insert` may return
|
||||
@@ -439,7 +454,19 @@ custody.
|
||||
`for_each_addressed_id` has a default implementation that visits every id,
|
||||
which is correct but makes the exchange read every broadcast observation on
|
||||
each poll just to find the few addressed ones. Override it if your backend can
|
||||
filter by `to` cheaply, e.g. with an index or a separate table.
|
||||
filter by `dest` cheaply, e.g. with an index or a separate table. The
|
||||
filtering is only an optimisation: core re-checks `dest` and `is_released` on
|
||||
every id a backend yields.
|
||||
|
||||
A store that keeps `sig` must return it verbatim. Core never calls `flush`;
|
||||
the application decides how often, because on flash every flush costs wear.
|
||||
A backend that holds data across power-off can also implement the opt-in
|
||||
`WallClockStore` trait, which lets the host pass in trusted wall time through
|
||||
`Node::set_wall_clock` (see [Trusted time](#trusted-time)).
|
||||
|
||||
With the `conformance` feature, `tines_core::store_conformance` checks a
|
||||
backend against the trait contract (dedupe, age lineage, saturating ticks,
|
||||
release, `sig` round-trip, trim order, no tombstones, `flush`).
|
||||
|
||||
## The simulator
|
||||
|
||||
@@ -508,13 +535,21 @@ flowchart LR
|
||||
A robot runs either the service or the ROS2 node, not both: each owns its
|
||||
`Node` and its log.
|
||||
|
||||
- **`tines-store`**: `LogStore`, an append-only log file.
|
||||
- **`tines-store`**: `LogStore`, an append-only log file (format v2; an
|
||||
older log is refused with an error that says to delete it).
|
||||
- Each record is checksummed (CRC-32C). Recovery skips a corrupt record and
|
||||
truncates only a torn tail.
|
||||
- Releases are written as they happen.
|
||||
- A boot counter provides `first_seq()`.
|
||||
- `flush()` writes a clock marker, so ages carry across a restart, and
|
||||
fsyncs the log.
|
||||
- `Store::flush(now)`, reached through `Node::flush`, writes a clock marker
|
||||
at `now` and fsyncs the log, so ages carry across a restart. An earlier
|
||||
`now` is clamped.
|
||||
- What the log held at open is quarantined until the host has trusted
|
||||
wall time (see [Trusted time](#trusted-time)).
|
||||
- The log is rewritten as a snapshot at open and after the wall-clock
|
||||
fold, through a `<log>.snapshot-tmp` file that's fsynced and renamed
|
||||
over it. That keeps the fold's results and ages across any number of
|
||||
reboots, and compacts the log.
|
||||
- The file is created `0600` and locked, so only one process can open it.
|
||||
- **`tines-bt`**: BLE over BlueZ, using L2CAP SeqPacket channels (one
|
||||
frame per packet).
|
||||
@@ -527,8 +562,9 @@ A robot runs either the service or the ROS2 node, not both: each owns its
|
||||
- **`tines`**: the standalone service, for robots without ROS2.
|
||||
- It runs the exchange on a poll loop and answers `Observe`, `Belief`,
|
||||
`Events` and `Status` requests on a Unix socket.
|
||||
- It's configured by a JSON file: `node_id`, `peer`, `log_path`,
|
||||
`socket_path` and `poll_interval_ms`.
|
||||
- It's configured by a JSON file (below). Library users call
|
||||
`tines::serve(node, socket_path, clock, poll_interval_ms,
|
||||
peer_time_window_ms)`.
|
||||
- **`tines-ros2`**: a ROS2 node.
|
||||
- It publishes each tines topic under `/tines/` and confirmed deliveries on
|
||||
`/tines/receipts`.
|
||||
@@ -536,12 +572,73 @@ A robot runs either the service or the ROS2 node, not both: each owns its
|
||||
- rclrs is behind the `ros2` feature, so the crate builds without a ROS2
|
||||
toolchain.
|
||||
|
||||
The service's config file is a JSON object. An unknown key is an error, so a
|
||||
typo can't silently fall back to a default.
|
||||
|
||||
| Key | Default | Meaning |
|
||||
|---------------------------|--------------|---------|
|
||||
| `node_id` | required | this robot's `NodeId`, 32 hex chars |
|
||||
| `peer` | required | the one peer's `NodeId`, 32 hex chars |
|
||||
| `log_path` | required | the `LogStore` log file |
|
||||
| `socket_path` | required | the IPC Unix socket |
|
||||
| `poll_interval_ms` | 20 | exchange poll cadence |
|
||||
| `trusted_clock` | unset | `"system"` to trust the system clock; the only accepted value |
|
||||
| `peer_time_window_ms` | 10000 | how long to collect peer time estimates before adopting the latest |
|
||||
| `unknown_downtime_age_ms` | 24 h | age floor for held broadcasts when the downtime is unknown |
|
||||
|
||||
The hosts use a monotonic millisecond clock that starts at 0 when the
|
||||
process does. Neither host sets `observed_at` yet.
|
||||
process does.
|
||||
|
||||
### Trusted time
|
||||
|
||||
Ages are relative, but a robot that was powered off can't tell from ticks how
|
||||
long it was off. So the hosts open the store before they know the wall time,
|
||||
quarantine what it held (not offered, delivered or acked, but still deduped),
|
||||
and adopt a trusted wall time **once per run**:
|
||||
|
||||
1. **A local source.** The service adopts the system clock at startup when
|
||||
`trusted_clock = "system"`. tines-ros2's `TinesNode` has
|
||||
`trust_system_clock(now)`, and `trust_wall_clock(wall_ms, now)` for an RTC
|
||||
or GPS reading; call one right after construction.
|
||||
2. **Otherwise, peers.** From the first plausible estimate a peer's
|
||||
observations give (`observed_at + age_ms`), the host collects estimates
|
||||
for `peer_time_window_ms` (10 s by default; `set_peer_time_window_ms` in
|
||||
tines-ros2), then adopts the latest.
|
||||
3. **Otherwise** the quarantine stays. The robot's own fail-safe is outside
|
||||
tines.
|
||||
|
||||
Only a wall time between 2026-01-01 and about 2126 is adopted
|
||||
(`tines::trust::WALL_FLOOR_MS` and `WALL_CEILING_MS`), from a peer or from
|
||||
the system clock. A board that booted before NTP and reads 1970 is logged and
|
||||
ignored, and the host falls back to peers. Adopting a time runs the store's
|
||||
fold and flushes at once. If the log's last clock marker carries a wall time
|
||||
no later than the adopted one, the fold adds the downtime to held ages.
|
||||
Otherwise the downtime is unknown: the fold expires held addressed messages
|
||||
and floors held broadcasts at `unknown_downtime_age_ms`. From then on
|
||||
the host stamps its observations' `observed_at`; before, they carry `None`.
|
||||
`TinesNode::has_trusted_time` reports whether a time was adopted.
|
||||
|
||||
The system clock is read once, at startup. Under systemd, order the service
|
||||
after clock sync:
|
||||
|
||||
```ini
|
||||
[Unit]
|
||||
Wants=time-sync.target
|
||||
After=time-sync.target
|
||||
```
|
||||
|
||||
`time-sync.target` only waits for a synced clock when the sync service
|
||||
supports it (`systemd-timesyncd` with `systemd-time-wait-sync.service`
|
||||
enabled, or chrony's equivalent).
|
||||
|
||||
Peer time is unauthenticated until 1.1, and an adopted time isn't corrected
|
||||
while the host runs; see F19 in [`THREAT_MODEL.md`](THREAT_MODEL.md).
|
||||
|
||||
**Current limits** (tracked in [`THREAT_MODEL.md`](THREAT_MODEL.md)):
|
||||
- The service talks to one configured `peer`.
|
||||
- The hosts don't yet flush on a timer.
|
||||
- The hosts don't flush on a timer or at shutdown. The service flushes after
|
||||
64 productive poll rounds, and both hosts flush when they adopt trusted
|
||||
time.
|
||||
- The host-hardening work (trim defaults, bounded channels, socket
|
||||
permissions, ROS2 isolation) hasn't landed.
|
||||
|
||||
@@ -564,23 +661,24 @@ tines/
|
||||
tines-ros2/ std: the ROS2 node (rclrs behind the `ros2` feature).
|
||||
```
|
||||
|
||||
Fixed limits, all compile-time constants in `tines-core/src/consts.rs`:
|
||||
The public constants, in `tines_core::consts`, are the wire constants. They
|
||||
are frozen for wire v1: changing one is a `WIRE_VERSION` bump.
|
||||
|
||||
| Constant | Value | Meaning |
|
||||
|-------------------------|--------|------------------------------------------|
|
||||
| `MAX_TOPIC` | 64 | topic bytes |
|
||||
| `MAX_PAYLOAD` | 1024 | payload bytes |
|
||||
| `MAX_PEERS` | 8 | peers tracked at once |
|
||||
| `SUMMARY_BYTES` | 512 | full Bloom summary size |
|
||||
| `BLOOM_K` | 3 | hash functions per id |
|
||||
| `SUMMARY_INTERVAL_MS` | 2000 | re-summary period per peer |
|
||||
| `REASSEMBLY_TIMEOUT_MS` | 5000 | partial observation discard time |
|
||||
| `SEND_QUEUE_CAP` | 256 | ids queued per peer |
|
||||
| `MAX_FRAME` | 1500 | largest frame (and MTU) supported |
|
||||
| `ACK_IDS_CAP` | 90 | ids in one `Ack` frame |
|
||||
| `EVENT_QUEUE_CAP` | 64 | confirmed deliveries awaiting `drain_events` |
|
||||
| `BCAST_SENT_CAP` | 256 | broadcast sends remembered per peer, to validate acks |
|
||||
| `ACK_BUFFER_CAP` | 256 | received ids waiting to be acked, per kind |
|
||||
| Constant | Value | Meaning |
|
||||
|-----------------|-------|------------------------------------------|
|
||||
| `WIRE_VERSION` | 4 | the first byte of every frame |
|
||||
| `MAX_TOPIC` | 64 | topic bytes |
|
||||
| `MAX_PAYLOAD` | 1024 | payload bytes |
|
||||
| `MAX_FRAME` | 1500 | largest frame (and MTU) supported |
|
||||
| `SUMMARY_BYTES` | 512 | full Bloom summary size |
|
||||
| `ACK_IDS_CAP` | 90 | ids in one `Ack` frame |
|
||||
| `MAX_SIG` | 64 | signature bytes |
|
||||
| `BLOOM_K` | 3 | hash functions per id |
|
||||
|
||||
Local capacities and timings (peers tracked at once, the re-summary interval,
|
||||
queue sizes, the idle-eviction window, and so on) are internal defaults, not
|
||||
public API. They can change in a minor release, and a later 1.x release will
|
||||
make them configurable.
|
||||
|
||||
## Building and CI
|
||||
|
||||
@@ -593,7 +691,15 @@ sh scripts/ci.sh
|
||||
```
|
||||
|
||||
GitHub Actions runs the same script on every push to `master` and on every
|
||||
pull request.
|
||||
pull request. Two more jobs run there:
|
||||
|
||||
- **MSRV:** builds `tines-core` on Rust 1.82 (its `rust-version`), with and
|
||||
without default features and for `thumbv7em-none-eabihf`. It builds only,
|
||||
because a test dependency needs a newer compiler.
|
||||
- **Fuzz:** a scheduled nightly job runs `cargo-fuzz` on the `fuzz/` crate's
|
||||
`node` target, in both its random-bytes and adversarial-peer modes, and
|
||||
keeps any crash reproducer. A bounded proptest twin of the same harness runs
|
||||
in the normal test suite.
|
||||
|
||||
## Stability
|
||||
|
||||
@@ -637,7 +743,7 @@ The specs are authoritative; the plans argue from them.
|
||||
- **Fleet deployment:** `docs/superpowers/specs/2026-10-18-tines-fleet-deployment-design.md`
|
||||
- **Fleet fixes:** `docs/superpowers/specs/2026-10-19-tines-fleet-fixes.md`
|
||||
- **Host hardening (next for the hosts):** `docs/superpowers/specs/2026-10-19-tines-host-hardening.md`
|
||||
- **tines-core 1.0 (next for the core):** `docs/superpowers/specs/2026-10-18-tines-1-0-design.md`
|
||||
- **tines-core 1.0 (implemented on `feat/tines-core-1-0`; publish pending):** `docs/superpowers/specs/2026-10-18-tines-1-0-design.md`
|
||||
- **Threat model:** [`THREAT_MODEL.md`](THREAT_MODEL.md)
|
||||
- **Plans:** `docs/superpowers/plans/`
|
||||
- **Changes:** [`CHANGELOG.md`](CHANGELOG.md)
|
||||
|
||||
Reference in New Issue
Block a user