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:
@@ -18,7 +18,7 @@ Every task's requirements implicitly include this section.
|
||||
- `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.
|
||||
- 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).
|
||||
- **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".
|
||||
@@ -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.
|
||||
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.
|
||||
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.
|
||||
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/lib.rs` — the 1.0 public surface + `#![deny(missing_docs)]`.
|
||||
- `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/src/ipc.rs`, `tines-ros2/src/node.rs` — host consumers of `NodeEvent`/`flush`/`observed_at`.
|
||||
- `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"`.
|
||||
|
||||
### 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:**
|
||||
- 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/tests/persistence.rs` (downtime tests, reworked per the amendment)
|
||||
- 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`)
|
||||
- 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` (quarantine and downtime tests)
|
||||
|
||||
**Interfaces:**
|
||||
- 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)
|
||||
|
||||
@@ -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")
|
||||
|
||||
**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:**
|
||||
- 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`
|
||||
definition (2026-10-19); review fixes applied. v6 added §10 (core hardening
|
||||
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`,
|
||||
`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.
|
||||
|
||||
1.0 handles each gap:
|
||||
- **Power-off time** is recovered by the store, from a wall clock, when it
|
||||
opens (§9). The fleet assumes every robot has a clock it can trust
|
||||
across a sleep, an RTC or GPS (decision G). It's cheap, and robots sleep
|
||||
through charge cycles.
|
||||
- **Power-off time** is recovered by the store from a wall clock, once
|
||||
the robot has trusted time (§9). The fleet assumes robots have a clock
|
||||
they can trust across a sleep, an RTC or GPS (decision G). It's cheap,
|
||||
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,
|
||||
`THREAT_MODEL.md` F18).
|
||||
- **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
|
||||
not allowed.
|
||||
- **Ages across downtime (decision G).** The same format bump adds a
|
||||
wall-clock reading to the clock marker `flush` writes: the trusted wall
|
||||
time in milliseconds since the Unix epoch.
|
||||
- **A host doesn't open the store until it has a trusted time source**
|
||||
(Kyle, 2026-10-20), for example an RTC it trusts or a GPS fix. Until
|
||||
then the robot runs no tines. That's acceptable because sharing is
|
||||
opportunistic. `LogStore::open` therefore takes the trusted wall time
|
||||
as a plain `u64` (ms since the Unix epoch), not an `Option`.
|
||||
- **The marker's wall time is derived, not passed in.** `flush(now)`
|
||||
keeps its `Tick`-only signature (decision F). `LogStore` records the
|
||||
wall time and tick at `open`, and each clock marker carries
|
||||
`wall_at_open + (now − tick_at_open)`. Because the store is never open
|
||||
without trusted time, every marker this format writes has a wall time.
|
||||
- **Downtime known:** the log has a marker with a wall time, and the wall
|
||||
time at open is ≥ it.
|
||||
`open` adds `now − marker` to every held observation's age,
|
||||
saturating, on top of the tick fold it already does.
|
||||
- **Downtime unknown:** the log has no clock marker (the previous run
|
||||
never flushed), or the wall clock went backwards. `open` treats every held **addressed** observation as
|
||||
expired: its age is set past `lifetime_ms`, so core never delivers or
|
||||
forwards it, and `open` reports the count once. A custodian that can't
|
||||
tell how long it slept must not deliver a 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:** they're kept, but their age
|
||||
isn't trusted. `open` raises every held broadcast's age to at least
|
||||
`unknown_downtime_age_ms`, an open option the host configures.
|
||||
- They lose `LatestWins` to any fresher fact and age out with the rest.
|
||||
- They 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, and that's option 2 (drop) by another name.
|
||||
wall-clock reading to the clock marker `flush` writes: trusted wall time
|
||||
in milliseconds since the Unix epoch, when the store has one.
|
||||
- **The store opens before trusted time; what it already held is
|
||||
quarantined** (Kyle, 2026-10-20; this replaces the earlier "don't open
|
||||
until trusted time" rule). A robot can then run tines, and learn the
|
||||
time from a peer, before it trusts its own clock.
|
||||
- `LogStore::open(path, now)` takes no wall time.
|
||||
- Every observation already in the log is quarantined until the fold
|
||||
runs. It's left out of summaries, custody offers and
|
||||
`for_each_topic` (so beliefs don't see it). It isn't trimmed on its
|
||||
untrusted age.
|
||||
- For a quarantined addressed observation, `is_released` reports
|
||||
`true`, so core neither forwards it nor acks a duplicate of it (it
|
||||
won't take custody back of something it may be about to expire).
|
||||
`contains` still reports every held id, so dedupe works.
|
||||
- Observations inserted after open are normal: their ages are this
|
||||
boot's ticks, and they need no fold.
|
||||
- **`LogStore::set_wall_clock(wall_now: u64, now: Tick)`** runs the fold,
|
||||
once, when the host first has trusted time. It's a `LogStore` method,
|
||||
not a `Store` trait method: core never deals with wall time.
|
||||
- **Downtime known:** the log's last clock marker has a wall time, and
|
||||
`wall_now` ≥ it. Every quarantined observation's age gains the
|
||||
downtime: the wall time elapsed since that marker, minus the ticks
|
||||
already counted since this open. Saturating, on top of the tick fold
|
||||
`open` already did.
|
||||
- **Downtime unknown:** the last marker has no wall time, the log has
|
||||
no marker, or the wall clock went backwards (marker ahead of
|
||||
`wall_now`). Every quarantined **addressed** observation is expired:
|
||||
its age is set past `lifetime_ms`, so core never delivers or forwards
|
||||
it. A custodian that can't tell how long it slept must not deliver a
|
||||
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
|
||||
trim defaults).
|
||||
- Every `open` that runs a fold reports the downtime it applied, or that
|
||||
it was unknown, once (stderr plus a counter, like the skipped-record
|
||||
count).
|
||||
- Then the quarantine lifts, and the fold reports once what it did
|
||||
(downtime applied or unknown, plus how many addressed observations
|
||||
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
|
||||
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
|
||||
@@ -782,10 +821,15 @@ The review's other fixes are applied too:
|
||||
for robots that sleep through charge cycles.
|
||||
- Stores add power-off time on open, using a wall-clock reading in the
|
||||
flush marker.
|
||||
- Hosts don't open the store until they have a trusted time source, so
|
||||
`LogStore::open` takes a plain `u64` wall time. The marker's wall time
|
||||
is derived from the wall time and tick at open, so `Store::flush(now)`
|
||||
stays `Tick`-only.
|
||||
- The store opens before trusted time, and holds what was already in
|
||||
the log in quarantine until `LogStore::set_wall_clock(wall_now, now)`
|
||||
runs the fold. Trusted time comes from the robot's own RTC or GPS, or
|
||||
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:
|
||||
possibly delivering an expired directive is worse than possibly
|
||||
dropping a valid one, since sharing is opportunistic.
|
||||
|
||||
Reference in New Issue
Block a user