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>
This commit is contained in:
2026-10-20 01:53:55 -07:00
co-authored by Claude Opus 5.5
parent ce4e2ba9ca
commit c3be9d0f83
2 changed files with 156 additions and 57 deletions
@@ -18,7 +18,7 @@ Every task's requirements implicitly include this section.
- `Addr` is never mapped to `NodeId` by core. - `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. - **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). - 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**: a host opens the store only once it has a trusted time source, `LogStore::open(path, now, wall_now: u64)` takes a plain `u64`, and the marker's wall time is derived (`wall_at_open + (now − tick_at_open)`). - 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). - `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. - **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". - 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".
@@ -31,7 +31,10 @@ The six failure modes the spec implies but no single task's tests exercise, most
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. 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. 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. 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. **A store opened with unknown downtime** (no clock marker, or a wall clock that went backwards) must expire held addressed messages (never deliver a directive that may have outlived `lifetime_ms`) and floor held broadcasts at `unknown_downtime_age_ms`, not deliver either as-is. → pinned in Task 10. 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. 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. 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.
@@ -47,7 +50,7 @@ The six failure modes the spec implies but no single task's tests exercise, most
- `tines-core/src/store.rs` — `Store` trait: `is_empty`, `flush`; the `store_conformance` module. - `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/src/lib.rs` — the 1.0 public surface + `#![deny(missing_docs)]`.
- `tines-core/tests/vectors.rs` — pinned identity + golden wire vectors. - `tines-core/tests/vectors.rs` — pinned identity + golden wire vectors.
- `tines-store/src/log.rs` — format v2 + wall-clock marker + downtime fold. - `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-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/src/ipc.rs`, `tines-ros2/src/node.rs` — host consumers of `NodeEvent`/`flush`/`observed_at`.
- `tines-bt/src/transport.rs` — `frame_sender` import site. - `tines-bt/src/transport.rs` — `frame_sender` import site.
@@ -249,26 +252,78 @@ In `tines-core/src/wire.rs` (mod tests): `encode_prepends_version_byte` (encode
- [ ] **Step 5: Commit** — `git commit -m "fix(core): peer pinning, idle eviction, replay and queue hardening"`. - [ ] **Step 5: Commit** — `git commit -m "fix(core): peer pinning, idle eviction, replay and queue hardening"`.
### Task 10: tines-store wall-clock marker + age across downtime (§9, decision G as amended) ### Task 10: tines-store quarantine, wall-clock marker and downtime fold (§9, decision G as amended)
**Files:** **Files:**
- Modify: `tines-store/src/log.rs` (wall-clock marker with derived wall time; `open(path, now, wall_now)`; downtime fold; addressed expiry; broadcast floor) - 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` (downtime tests, reworked per the amendment) - Modify: `tines-store/tests/persistence.rs` (quarantine and downtime tests)
- Modify: `tines/src/main.rs` + `tines/src/ipc.rs`, `tines-ros2/src/node.rs` (hosts set `observed_at` and wait for a trusted time source before `LogStore::open`)
**Interfaces:** **Interfaces:**
- Consumes: `Observation` with `observed_at` (Task 3), `Store::flush(now)` (Task 6), the `format_version` bump (Task 3). - 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, wall_now: u64) -> Result<Self, StoreError>` — a plain `u64` (ms since the Unix epoch), not `Option`. `LogStore` records wall time and tick at open; each `flush(now)` clock marker carries `wall_at_open + (now − tick_at_open)`. "Downtime unknown" now means: the log has no clock marker (the previous run never flushed), or the wall clock went backwards (marker wall time ahead of `wall_now`). A `LogStore` option for `unknown_downtime_age_ms` floors held broadcasts. - 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** — `downtime_known_advances_ages` (marker at wall 1000, reopen at 2000 → every held age +1000, saturating); `downtime_unknown_expires_addressed` (a log with **no marker** → held addressed obs never delivered, count reported once); `downtime_unknown_floors_broadcasts` (no marker → held broadcast age raised to `unknown_downtime_age_ms`); `clock_went_backwards_is_unknown` (marker wall time ahead of `wall_now` → treated as unknown); `marker_wall_time_is_derived` (open at wall W, tick T; flush at tick T+5000 → the marker carries W+5000); `open_reports_downtime_once` (a fold/unknown-downtime case is reported once on stderr plus a counter, like `skipped_records`). - [ ] **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` fails on the new signature/behaviour. - [ ] **Step 2: Run, expect failure** — `cargo test -p tines-store`.
- [ ] **Step 3: Implement** — per §9 as amended: hosts wait for a trusted time source (an RTC they trust, or a GPS fix) before `open`, and set `observed_at` to `Some(wall ms)` on every observation they make; `unknown_downtime_age_ms` is configured below the host's trim `max_age_ms` — since the trim defaults are still empty (host hardening), record that this floor's `max_age_ms` check waits for host hardening where it isn't configurable yet. Add a per-host test that an observation made through the IPC carries `Some(wall ms)`. - [ ] **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` + `cargo test -p tines` + `docker build -f Dockerfile.ros2 -t tines-ros2-check .`. - [ ] **Step 4: Run, expect pass** — `cargo test -p tines-store`, then `sh scripts/ci.sh`.
- [ ] **Step 5: Commit** — `git commit -m "feat(store): wall-clock marker and downtime fold"`. - [ ] **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) ### Task 11: Evidence — pinned vectors + MSRV (§7)
@@ -310,7 +365,7 @@ In `tines-core/src/wire.rs` (mod tests): `encode_prepends_version_byte` (encode
### Task 13: Docs, compatibility policy, and the 1.0.0 release (§8, "Done means") ### Task 13: Docs, compatibility policy, and the 1.0.0 release (§8, "Done means")
**Files:** **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, and update F18 — Task 6 fixed its stale clock-marker tick), `Cargo.toml` + dependent crates (`tines-core` → 1.0.0, hosts depend on `"1"`) - 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:** **Interfaces:**
- Consumes: everything above. - Consumes: everything above.
@@ -5,7 +5,8 @@ Status: approved by Kyle (2026-10-20). Kyle decided D1–D5 and Q1–Q3
(2026-10-18) and the review decisions R1–R3 and the `observed_at` (2026-10-18) and the review decisions R1–R3 and the `observed_at`
definition (2026-10-19); review fixes applied. v6 added §10 (core hardening definition (2026-10-19); review fixes applied. v6 added §10 (core hardening
from `THREAT_MODEL.md`) and the adversarial fuzz mode; Kyle reviewed and from `THREAT_MODEL.md`) and the adversarial fuzz mode; Kyle reviewed and
approved both (2026-10-20). Decisions F (`flush`) and G (age across downtime) added 2026-10-20. approved both (2026-10-20). Decisions F (`flush`) and G (age across downtime) added 2026-10-20;
G amended the same day (quarantine on open, time from peers).
Builds on: `2026-10-16-tines-design.md`, `2026-10-18-tines-addressed-delivery.md`, Builds on: `2026-10-16-tines-design.md`, `2026-10-18-tines-addressed-delivery.md`,
`2026-10-18-tines-fleet-deployment-design.md` (assumed fully implemented) `2026-10-18-tines-fleet-deployment-design.md` (assumed fully implemented)
@@ -234,10 +235,11 @@ pub struct Dest { pub to: NodeId, pub lifetime_ms: u64 }
- `trim` by `max_age_ms` keeps what it should drop. - `trim` by `max_age_ms` keeps what it should drop.
1.0 handles each gap: 1.0 handles each gap:
- **Power-off time** is recovered by the store, from a wall clock, when it - **Power-off time** is recovered by the store from a wall clock, once
opens (§9). The fleet assumes every robot has a clock it can trust the robot has trusted time (§9). The fleet assumes robots have a clock
across a sleep, an RTC or GPS (decision G). It's cheap, and robots sleep they can trust across a sleep, an RTC or GPS (decision G). It's cheap,
through charge cycles. and robots sleep through charge cycles. A robot without trusted time
can take it from a peer's observation.
- **Crash time** is bounded by how often the host flushes (§4; host work, - **Crash time** is bounded by how often the host flushes (§4; host work,
`THREAT_MODEL.md` F18). `THREAT_MODEL.md` F18).
- **In-flight time** is negligible on the links tines targets, and is - **In-flight time** is negligible on the links tines targets, and is
@@ -616,42 +618,79 @@ the new `Observation` encoding. There is no migration:
that names the fix (delete the log). Silently misparsing an old log is that names the fix (delete the log). Silently misparsing an old log is
not allowed. not allowed.
- **Ages across downtime (decision G).** The same format bump adds a - **Ages across downtime (decision G).** The same format bump adds a
wall-clock reading to the clock marker `flush` writes: the trusted wall wall-clock reading to the clock marker `flush` writes: trusted wall time
time in milliseconds since the Unix epoch. in milliseconds since the Unix epoch, when the store has one.
- **A host doesn't open the store until it has a trusted time source** - **The store opens before trusted time; what it already held is
(Kyle, 2026-10-20), for example an RTC it trusts or a GPS fix. Until quarantined** (Kyle, 2026-10-20; this replaces the earlier "don't open
then the robot runs no tines. That's acceptable because sharing is until trusted time" rule). A robot can then run tines, and learn the
opportunistic. `LogStore::open` therefore takes the trusted wall time time from a peer, before it trusts its own clock.
as a plain `u64` (ms since the Unix epoch), not an `Option`. - `LogStore::open(path, now)` takes no wall time.
- **The marker's wall time is derived, not passed in.** `flush(now)` - Every observation already in the log is quarantined until the fold
keeps its `Tick`-only signature (decision F). `LogStore` records the runs. It's left out of summaries, custody offers and
wall time and tick at `open`, and each clock marker carries `for_each_topic` (so beliefs don't see it). It isn't trimmed on its
`wall_at_open + (now − tick_at_open)`. Because the store is never open untrusted age.
without trusted time, every marker this format writes has a wall time. - For a quarantined addressed observation, `is_released` reports
- **Downtime known:** the log has a marker with a wall time, and the wall `true`, so core neither forwards it nor acks a duplicate of it (it
time at open is ≥ it. won't take custody back of something it may be about to expire).
`open` adds `now − marker` to every held observation's age, `contains` still reports every held id, so dedupe works.
saturating, on top of the tick fold it already does. - Observations inserted after open are normal: their ages are this
- **Downtime unknown:** the log has no clock marker (the previous run boot's ticks, and they need no fold.
never flushed), or the wall clock went backwards. `open` treats every held **addressed** observation as - **`LogStore::set_wall_clock(wall_now: u64, now: Tick)`** runs the fold,
expired: its age is set past `lifetime_ms`, so core never delivers or once, when the host first has trusted time. It's a `LogStore` method,
forwards it, and `open` reports the count once. A custodian that can't not a `Store` trait method: core never deals with wall time.
tell how long it slept must not deliver a directive that may have - **Downtime known:** the log's last clock marker has a wall time, and
expired. Losing a possibly valid one is the lesser harm, because `wall_now` ≥ it. Every quarantined observation's age gains the
sharing is opportunistic and the originator can re-issue. downtime: the wall time elapsed since that marker, minus the ticks
- **Broadcasts when downtime is unknown:** they're kept, but their age already counted since this open. Saturating, on top of the tick fold
isn't trusted. `open` raises every held broadcast's age to at least `open` already did.
`unknown_downtime_age_ms`, an open option the host configures. - **Downtime unknown:** the last marker has no wall time, the log has
- They lose `LatestWins` to any fresher fact and age out with the rest. no marker, or the wall clock went backwards (marker ahead of
- They stay available as a last resort for topics nobody else has `wall_now`). Every quarantined **addressed** observation is expired:
reported. its age is set past `lifetime_ms`, so core never delivers or forwards
- The floor must be below the host's trim `max_age_ms`; otherwise the it. A custodian that can't tell how long it slept must not deliver a
next trim drops them, and that's option 2 (drop) by another name. directive that may have expired. Losing a possibly valid one is the
lesser harm, because sharing is opportunistic and the originator can
re-issue.
- **Broadcasts when downtime is unknown:** kept, but their age isn't
trusted. Every quarantined broadcast's age is raised to at least
`unknown_downtime_age_ms`, a `LogStore` option the host configures.
They lose `LatestWins` to any fresher fact, age out with the rest,
and stay available as a last resort for topics nobody else has
reported. The floor must be below the host's trim `max_age_ms`;
otherwise the next trim drops them, which is "drop" by another name.
The default is left to the builder (host hardening, F10, sets the The default is left to the builder (host hardening, F10, sets the
trim defaults). trim defaults).
- Every `open` that runs a fold reports the downtime it applied, or that - Then the quarantine lifts, and the fold reports once what it did
it was unknown, once (stderr plus a counter, like the skipped-record (downtime applied or unknown, plus how many addressed observations
count). expired), on stderr and as a counter, like the skipped-record count.
- **Clock markers.** After `set_wall_clock`, each `flush(now)` marker
carries `wall_at_set + (now − tick_at_set)`, so `Store::flush(now)`
stays `Tick`-only (decision F). A flush before trusted time writes a
marker with no wall time. If the robot then restarts, the next fold
finds no wall time and takes the unknown path, which is the safe one.
- **Where trusted time comes from.** In order:
1. the robot's own trusted source (an RTC it trusts, or a GPS fix);
2. otherwise, **another robot**, through any received observation that
carries `observed_at`. The estimate is `observed_at + age_ms`, plus
local ticks elapsed since that observation arrived. Because age is a
lower bound, this estimate is never later than the true time. The
host uses the observation with the smallest `age_ms` (the least
accumulated error), and only one inserted after this open: anything
older is quarantined.
3. otherwise, the robot's own fail-safe applies. That's outside tines.
The quarantine simply stays until trusted time arrives.
- **Peer time is unauthenticated until 1.1.** A peer can claim any
`observed_at`.
- A time too far *ahead* makes the downtime too long: addressed
messages expire early and facts look stale. That's the safe
direction.
- A time *behind* the marker is "clock went backwards", which takes the
unknown path. That's also safe.
- The cost is the robot's own `observed_at` stamps, which are wrong
until a trusted local source corrects them. `THREAT_MODEL.md` records
this.
- Hosts set `observed_at` on every observation they make once they have
trusted time, and leave it `None` before (R4).
- Hosts wipe pre-1.0 logs on upgrade. That's acceptable because no robot - Hosts wipe pre-1.0 logs on upgrade. That's acceptable because no robot
runs tines yet, and none will be deployed with a pre-1.0 `tines-core`. runs tines yet, and none will be deployed with a pre-1.0 `tines-core`.
- The header is what makes a later migration possible if one is ever - The header is what makes a later migration possible if one is ever
@@ -782,10 +821,15 @@ The review's other fixes are applied too:
for robots that sleep through charge cycles. for robots that sleep through charge cycles.
- Stores add power-off time on open, using a wall-clock reading in the - Stores add power-off time on open, using a wall-clock reading in the
flush marker. flush marker.
- Hosts don't open the store until they have a trusted time source, so - The store opens before trusted time, and holds what was already in
`LogStore::open` takes a plain `u64` wall time. The marker's wall time the log in quarantine until `LogStore::set_wall_clock(wall_now, now)`
is derived from the wall time and tick at open, so `Store::flush(now)` runs the fold. Trusted time comes from the robot's own RTC or GPS, or
stays `Tick`-only. else from a peer's observation (`observed_at + age_ms`, smallest age
wins). The robot's own fail-safe covers the case where neither arrives.
The marker's wall time is derived from the wall time and tick at
`set_wall_clock`, so `Store::flush(now)` stays `Tick`-only. (Amended
twice on 2026-10-20; this supersedes "don't open the store until
trusted time".)
- When downtime can't be known, held addressed messages are expired: - When downtime can't be known, held addressed messages are expired:
possibly delivering an expired directive is worse than possibly possibly delivering an expired directive is worse than possibly
dropping a valid one, since sharing is opportunistic. dropping a valid one, since sharing is opportunistic.