car_sync/oplog.rs
1//! The append-only, replica-tagged operation log.
2//!
3//! [`OpRecord`] follows `docs/proposals/multi-device-sync.md` §"The frame:
4//! sync events, not files" field-for-field, with two B1 specifics:
5//!
6//! - **`op_id` is content-derived via the shipped B7 discipline**
7//! (`car_proto::deterministic_run_id`'s SHA-256 + `0x1f` field separators;
8//! the proposal's `blake3(payload)` is the same content-addressing idea —
9//! we reuse the hash the codebase already standardized on rather than add a
10//! dependency). The digest covers `device_id ‖ seq ‖ prev ‖ hlc ‖ scope ‖
11//! surface ‖ canonical(payload)`, so the id is simultaneously the natural
12//! dedup key for op *retransmission* AND a tamper-evident cover of the
13//! record, including its position in the device chain. Logical-entity
14//! dedup across devices (the proposal's "conversations dedup on
15//! (speaker,text,timestamp); knowledge on fact_id") happens at the fold's
16//! stable-key level, not on `op_id` — see [`OpRecord::stable_key`].
17//! **Event-stream surfaces are the exception**: routing observations fold
18//! as a MULTISET (the proposal replays "the merged multiset of
19//! observations"), so they key by `op_id` — two byte-identical
20//! observations are two events, and only retransmission dedups. See
21//! [`Surface::is_event_stream`] / [`OpRecord::fold_key`].
22//! - **The HLC is shape-only in B1.** [`Hlc`] carries the proposal's
23//! `{wall_ms, counter, device_id}` total order; [`DeviceLog`] stamps pure
24//! Lamport values into `wall_ms` (`counter` stays 0) with the standard
25//! send/receive rules, so nothing in this crate reads a wall clock. B3
26//! replaces the stamp *source* with the true hybrid clock — the wire shape
27//! and the fold are unchanged.
28//!
29//! Order-verifiability: each op carries a per-device `seq` and the `prev`
30//! op_id of the same device's preceding op — a per-device hash chain.
31//! [`verify_log`] recomputes every id and walks every chain, so a loaded or
32//! received log proves its own order and integrity.
33//!
34//! **Honesty note — device identity is asserted, not authenticated.** The
35//! hash chain proves internal consistency (nothing was reordered or mutated
36//! after the fact), but a forger who recomputes the hashes can emit a chain
37//! claiming any `device_id` and it will pass [`verify_log`]. Cryptographic
38//! device identity (signing ops/checkpoints with a device key) lands with
39//! the checkpoint/relay slices (B4/B6); until then, trust in a log's origin
40//! comes from the transport that delivered it.
41
42use serde::{Deserialize, Serialize};
43use serde_json::Value;
44use sha2::{Digest, Sha256};
45use std::collections::BTreeMap;
46use std::fmt;
47use std::sync::Arc;
48
49/// Hybrid-logical-clock stamp — the proposal's `{wall_ms, counter, device_id}`.
50/// The derived `Ord` (field order) IS the total order every device agrees on.
51/// B1 stamped pure Lamport values into this shape; B3's [`HlcClock`] supplies
52/// the real hybrid clock — the wire shape is unchanged, exactly as promised.
53#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
54pub struct Hlc {
55 pub wall_ms: u64,
56 pub counter: u32,
57 pub device_id: String,
58}
59
60/// An injectable wall-clock reading (milliseconds since the Unix epoch).
61///
62/// Library logic never reads the system time directly — the clock is a
63/// value the caller hands in (the `run_cascade`/`EffectModel` injection
64/// idiom), so tests stay fully deterministic. Production callers pass
65/// [`system_clock`]; [`DeviceLog::new`] defaults to [`logical_clock`]
66/// (always 0), under which the HLC degenerates to exactly B1's pure
67/// Lamport order (the wall component never advances, so every event is a
68/// counter tick).
69pub type WallClock = Arc<dyn Fn() -> u64 + Send + Sync>;
70
71/// The real wall clock — the ONE place system time enters this crate, and
72/// only ever by explicit caller opt-in.
73pub fn system_clock() -> WallClock {
74 Arc::new(|| {
75 std::time::SystemTime::now()
76 .duration_since(std::time::UNIX_EPOCH)
77 .map(|d| d.as_millis() as u64)
78 .unwrap_or(0)
79 })
80}
81
82/// A wall clock that never advances (always 0): the HLC's degenerate
83/// pure-Lamport mode — B1's stamp semantics, now produced by the same
84/// hybrid-clock code path.
85///
86/// **Counter cap (binding on B6 daemon wiring).** In this mode the wall
87/// component is pinned at 0, so *every* event is a counter tick and the
88/// `u32` counter never resets — [`HlcClock::tick`] panics after `2^32`
89/// events on one device without a wall advance (~4.3 billion). Fine for
90/// tests and short-lived tools, but a long-running daemon MUST NOT ship the
91/// default: pass [`system_clock`] (or a real monotonic source) via
92/// [`DeviceLog::with_wall_clock`], under which the counter resets every
93/// millisecond the wall advances and the cap is unreachable in practice.
94/// (Daemon wiring is B6; this is the note that keeps the default out of
95/// production.)
96pub fn logical_clock() -> WallClock {
97 Arc::new(|| 0)
98}
99
100/// The real hybrid logical clock (B3) — the proposal's `{wall_ms, counter}`
101/// state with the standard HLC send/receive rules (Kulkarni et al.):
102///
103/// - **tick** (local/send event): `l' = max(l, wall_now)`; if the wall
104/// didn't advance past everything witnessed, bump the counter, else reset
105/// it — the issued stamp is strictly greater than every stamp this clock
106/// has issued or observed.
107/// - **observe** (receive rule): fold a remote stamp into `(l, c)` as a
108/// component-wise max, so the *next* tick lands strictly above it.
109///
110/// Monotone by construction under clock **skew** (a peer's future stamp is
111/// absorbed via `observe`; local ticks ride the counter until the local
112/// wall catches up), clock **regression** (a wall reading below `l` is
113/// ignored — the counter carries the order), and same-millisecond
114/// **bursts** (counter ties, broken across devices by `Hlc::device_id`).
115/// The wall component never runs *behind* the physical clock reading it
116/// was given, so `wall_ms` stays a meaningful timestamp bounded by the
117/// max skew among devices — the "hybrid" in HLC.
118#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
119pub struct HlcClock {
120 /// Max wall-clock ms witnessed (own readings and observed stamps).
121 l: u64,
122 /// Logical tie counter within `l`.
123 c: u32,
124}
125
126impl HlcClock {
127 pub fn new() -> Self {
128 Self::default()
129 }
130
131 /// Stamp a local event: strictly greater than every stamp previously
132 /// issued by or observed on this clock, regardless of what `wall_now`
133 /// reads (regression-safe).
134 pub fn tick(&mut self, wall_now: u64, device_id: &str) -> Hlc {
135 if wall_now > self.l {
136 self.l = wall_now;
137 self.c = 0;
138 } else {
139 self.c = self
140 .c
141 .checked_add(1)
142 .expect("HLC counter overflow: > u32::MAX events without wall-clock progress");
143 }
144 Hlc {
145 wall_ms: self.l,
146 counter: self.c,
147 device_id: device_id.to_string(),
148 }
149 }
150
151 /// Receive rule: fold an observed stamp so the next [`HlcClock::tick`]
152 /// lands strictly above it (and above everything observed before it).
153 pub fn observe(&mut self, remote: &Hlc) {
154 if remote.wall_ms > self.l {
155 self.l = remote.wall_ms;
156 self.c = remote.counter;
157 } else if remote.wall_ms == self.l && remote.counter > self.c {
158 self.c = remote.counter;
159 }
160 }
161
162 /// The max `(wall_ms, counter)` witnessed so far — the state a stamp
163 /// must exceed.
164 pub fn witnessed(&self) -> (u64, u32) {
165 (self.l, self.c)
166 }
167}
168
169/// Visibility regime — the proposal's "the `scope` field is the whole answer".
170#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
171#[serde(rename_all = "snake_case")]
172pub enum Scope {
173 /// Replicates only across one user's devices.
174 Personal,
175 /// Replicates to an org op-stream other members fold in.
176 Shared { org: String },
177}
178
179impl Scope {
180 /// Stable string form used in the `op_id` digest.
181 pub fn tag(&self) -> String {
182 match self {
183 Scope::Personal => "personal".to_string(),
184 Scope::Shared { org } => format!("shared:{org}"),
185 }
186 }
187}
188
189/// Which persisted surface an op mutates — the proposal's surfaces.
190///
191/// **`Intent` (B5) is a leased execution-intent surface.** Its [`Surface::tag`]
192/// string `"intent"` enters the `op_id` content digest and is therefore
193/// **frozen forever** — changing it would re-address every historical intent
194/// op. The enum is deliberately NOT `#[non_exhaustive]`: the repo bans the
195/// `_ =>` wildcards that would force, so a new surface variant is a compile
196/// error at every match — the intended review gate. The serde wire form stays
197/// a tagged union (`#[serde(rename_all = "snake_case")]`).
198#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
199#[serde(rename_all = "snake_case")]
200pub enum Surface {
201 Routing,
202 Declagent,
203 Conversation,
204 Knowledge,
205 Skill,
206 Registry { kind: String },
207 Trajectory,
208 Run,
209 /// Leased execution-intent ledger (B5): payload
210 /// `{id: run_id, agent_id, epoch, status}`. Folds under
211 /// [`FoldTier::Leased`] — LWW-per-run_id with monotone status **plus
212 /// per-agent epoch fencing**, so a stale-epoch write from a failed-over
213 /// lease holder loses deterministically at the fold. See [`crate::lease`].
214 Intent,
215}
216
217/// How a surface folds — the proposal's per-surface fold-rule tiers.
218#[derive(Debug, Clone, Copy, PartialEq, Eq)]
219pub enum FoldTier {
220 /// Union by stable ID (conversations, knowledge, skills, trajectories,
221 /// runs, routing *observations* — the grow-only tier).
222 GrowOnly,
223 /// LWW-register per record keyed by id, ordered by HLC (declagents and
224 /// the file registries) — NOT per file.
225 Registry,
226 /// Leased execution-intent tier (`Intent`, B5): LWW-per-run_id with
227 /// monotone status, **plus epoch fencing** — an intent whose `epoch` is
228 /// below its agent's max-seen epoch is dropped at the fold (a fenced
229 /// zombie writer after a lease failover loses deterministically, with no
230 /// wall-clock race). NOT grow-only. See [`crate::lease`]/[`crate::fold`].
231 Leased,
232}
233
234impl Surface {
235 /// Stable string form: the fold's grouping key and part of the `op_id`
236 /// digest.
237 pub fn tag(&self) -> String {
238 match self {
239 Surface::Routing => "routing".to_string(),
240 Surface::Declagent => "declagent".to_string(),
241 Surface::Conversation => "conversation".to_string(),
242 Surface::Knowledge => "knowledge".to_string(),
243 Surface::Skill => "skill".to_string(),
244 Surface::Registry { kind } => format!("registry:{kind}"),
245 Surface::Trajectory => "trajectory".to_string(),
246 Surface::Run => "run".to_string(),
247 // FROZEN (B5): part of the op_id digest — never change this string.
248 Surface::Intent => "intent".to_string(),
249 }
250 }
251
252 /// The proposal's fold-rule table. Routing observations are grow-only
253 /// log entries ("sync the observations, not the result"); the EMA replay
254 /// over them is the caller-injected [`crate::fold::SyncState::replay`].
255 pub fn fold_tier(&self) -> FoldTier {
256 match self {
257 Surface::Conversation
258 | Surface::Knowledge
259 | Surface::Skill
260 | Surface::Trajectory
261 | Surface::Run
262 | Surface::Routing => FoldTier::GrowOnly,
263 Surface::Declagent | Surface::Registry { .. } => FoldTier::Registry,
264 Surface::Intent => FoldTier::Leased,
265 }
266 }
267
268 /// Is this surface an **event stream** — a multiset keyed by `op_id`
269 /// rather than a set of content-deduped logical entities?
270 ///
271 /// Two surfaces are event streams, for the same structural reason but with
272 /// different downstream handling:
273 ///
274 /// - **Routing** — the proposal's "replays the merged **multiset** of
275 /// observations": `agent x succeeded` twice is two events that must both
276 /// reach the EMA replay.
277 /// - **Conversation** (B2, kernel-review correction) — a conversation turn
278 /// has exactly one author and propagates by op replication, so **op
279 /// identity IS turn identity**. Content-keying was a reproduced
280 /// data-loss bug: two genuine "yes" turns stamped at the same
281 /// payload-second (cached `now()`, rapid double-confirm) fold to one
282 /// entry. Keyed by `op_id`, a *resent* op dedups but two *distinct*
283 /// authorings never collapse. Unlike routing, conversation turns are
284 /// **independent** entries (no path-dependent replay), so they tolerate
285 /// `LastN` retention — see [`Surface::is_replay_stream`].
286 ///
287 /// Event-stream surfaces fold keyed by `op_id` — see [`OpRecord::fold_key`].
288 pub fn is_event_stream(&self) -> bool {
289 match self {
290 Surface::Routing | Surface::Conversation => true,
291 Surface::Declagent
292 | Surface::Knowledge
293 | Surface::Skill
294 | Surface::Registry { .. }
295 | Surface::Trajectory
296 | Surface::Run
297 | Surface::Intent => false,
298 }
299 }
300
301 /// Is this surface a **path-dependent replay** stream — one whose folded
302 /// result is recomputed from the ordered multiset (routing's EMA), so that
303 /// dropping ANY entry corrupts every device's recomputed value? Only these
304 /// are retention-forbidden (`compact` rejects any non-keep-all rule on
305 /// them). This is **narrower than [`Surface::is_event_stream`]**:
306 /// conversation is an event-stream multiset too, but its turns are
307 /// independent, so `LastN` over them is well-defined and allowed. Routing
308 /// is the only replay stream today; a new one is a compile-error here (no
309 /// `_` arm), the intended review gate.
310 pub fn is_replay_stream(&self) -> bool {
311 match self {
312 Surface::Routing => true,
313 Surface::Declagent
314 | Surface::Conversation
315 | Surface::Knowledge
316 | Surface::Skill
317 | Surface::Registry { .. }
318 | Surface::Trajectory
319 | Surface::Run
320 | Surface::Intent => false,
321 }
322 }
323}
324
325/// One state-changing operation in the oplog.
326#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
327pub struct OpRecord {
328 /// Content-derived id (see module docs): dedup key for retransmission
329 /// and tamper-evident cover of the whole record.
330 pub op_id: String,
331 /// Total-order stamp. Invariant: `hlc.device_id == device_id`.
332 pub hlc: Hlc,
333 /// The device (replica) that emitted the op. Matches the `replica`
334 /// strings `car_state::crdt` already uses.
335 pub device_id: String,
336 /// Per-device append index (0-based, contiguous).
337 pub seq: u64,
338 /// `op_id` of this device's previous op (`None` iff `seq == 0`) — the
339 /// per-device hash-chain link that makes the log order-verifiable.
340 pub prev: Option<String>,
341 pub scope: Scope,
342 pub surface: Surface,
343 /// Surface-specific payload (possibly E2E ciphertext in B6).
344 pub payload: Value,
345}
346
347/// Canonical, key-sorted, compact JSON — the deterministic serialization the
348/// `op_id` digest and [`crate::fold::state_hash`] are computed over.
349/// Independent of `serde_json`'s map-ordering configuration.
350pub fn canonical_json(v: &Value) -> String {
351 match v {
352 Value::Object(map) => {
353 let mut keys: Vec<&String> = map.keys().collect();
354 keys.sort();
355 let inner: Vec<String> = keys
356 .iter()
357 .map(|k| {
358 format!(
359 "{}:{}",
360 serde_json::to_string(k).expect("string serializes"),
361 canonical_json(&map[k.as_str()])
362 )
363 })
364 .collect();
365 format!("{{{}}}", inner.join(","))
366 }
367 Value::Array(items) => {
368 let inner: Vec<String> = items.iter().map(canonical_json).collect();
369 format!("[{}]", inner.join(","))
370 }
371 _ => serde_json::to_string(v).unwrap_or_default(),
372 }
373}
374
375// NOTE: this reimplements car-proto's B7 content-address discipline
376// (`deterministic_run_id`: SHA-256, 0x1f separators, 16-byte/32-hex prefix)
377// rather than depending on car-proto, which would drag the whole protocol
378// crate into this dependency-light core. Consolidating the discipline into a
379// shared home (car-proto exporting just the hasher, or a tiny common crate)
380// is a next-slice cleanup — keep the two in step until then.
381fn sha256_hex_32(fields: &[&str]) -> String {
382 let mut hasher = Sha256::new();
383 for (i, f) in fields.iter().enumerate() {
384 if i > 0 {
385 hasher.update(b"\x1f"); // B7's field separator — no concat collisions
386 }
387 hasher.update(f.as_bytes());
388 }
389 let digest = hasher.finalize();
390 digest.iter().take(16).map(|b| format!("{b:02x}")).collect()
391}
392
393impl OpRecord {
394 /// Build an op and stamp its content-derived id. Callers normally go
395 /// through [`DeviceLog::append`], which manages `seq`/`prev`/`hlc`.
396 pub fn new(
397 hlc: Hlc,
398 seq: u64,
399 prev: Option<String>,
400 scope: Scope,
401 surface: Surface,
402 payload: Value,
403 ) -> Self {
404 let device_id = hlc.device_id.clone();
405 let mut op = OpRecord {
406 op_id: String::new(),
407 hlc,
408 device_id,
409 seq,
410 prev,
411 scope,
412 surface,
413 payload,
414 };
415 op.op_id = op.compute_op_id();
416 op
417 }
418
419 /// Recompute the content-derived id from the record's fields (the B7
420 /// SHA-256 + `0x1f` discipline; `op-` + 32 hex chars).
421 pub fn compute_op_id(&self) -> String {
422 let hex = sha256_hex_32(&[
423 &self.device_id,
424 &self.seq.to_string(),
425 self.prev.as_deref().unwrap_or(""),
426 &self.hlc.wall_ms.to_string(),
427 &self.hlc.counter.to_string(),
428 &self.hlc.device_id,
429 &self.scope.tag(),
430 &self.surface.tag(),
431 &canonical_json(&self.payload),
432 ]);
433 format!("op-{hex}")
434 }
435
436 /// Does the stored id match the record's content?
437 pub fn id_valid(&self) -> bool {
438 self.op_id == self.compute_op_id()
439 }
440
441 /// The stable key the fold dedups logical entities on: the payload's
442 /// `"id"` string when present (the proposal's `fact_id` / record-id
443 /// keys), else the canonical content hash (which realizes e.g. the
444 /// conversation `(speaker,text,timestamp)` dedup — identical content is
445 /// one entity). Prefixed so the two forms can never collide.
446 ///
447 /// NOT used for event-stream surfaces (routing) — the fold keys those by
448 /// `op_id` via [`OpRecord::fold_key`], because an event stream is a
449 /// multiset: identical content is two events, not one entity.
450 pub fn stable_key(&self) -> String {
451 match self.payload.get("id").and_then(Value::as_str) {
452 Some(id) => format!("id:{id}"),
453 None => format!("h:{}", sha256_hex_32(&[&canonical_json(&self.payload)])),
454 }
455 }
456
457 /// The key the fold stores this op under: [`OpRecord::stable_key`] for
458 /// logical-entity surfaces, `op_id` for event-stream surfaces
459 /// ([`Surface::is_event_stream`] — the proposal's routing MULTISET:
460 /// every emitted observation survives the fold; only retransmission of
461 /// the *same* op dedups). Prefixes keep the three key forms (`id:`,
462 /// `h:`, `op:`) disjoint.
463 pub fn fold_key(&self) -> String {
464 if self.surface.is_event_stream() {
465 format!("op:{}", self.op_id)
466 } else {
467 self.stable_key()
468 }
469 }
470}
471
472/// A chain-verification failure from [`verify_log`].
473#[derive(Debug, Clone, PartialEq, Eq)]
474pub enum ChainError {
475 /// A record's stored `op_id` doesn't match its content.
476 IdMismatch { op_id: String },
477 /// `hlc.device_id` disagrees with the record's `device_id`.
478 DeviceMismatch { op_id: String },
479 /// Two ops from one device claim the same `seq`.
480 DuplicateSeq { device_id: String, seq: u64 },
481 /// A device's seqs aren't contiguous from its first present op.
482 SeqGap { device_id: String, expected: u64, found: u64 },
483 /// `prev` doesn't link to the device's preceding op (or `seq 0` has one).
484 PrevMismatch { op_id: String },
485 /// A device's HLC stamps aren't strictly increasing along its chain.
486 NonMonotonicHlc { op_id: String },
487 /// [`DeviceLog::resume`] was handed a log in which the resuming
488 /// device's own chain doesn't start at `seq 0` — a truncated tail.
489 /// Resuming from it would re-mint truncated seqs (a permanent chain
490 /// fork); go through `checkpoint::resume_anchored` instead.
491 TruncatedChain { device_id: String, first_seq: u64 },
492}
493
494impl fmt::Display for ChainError {
495 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
496 match self {
497 ChainError::IdMismatch { op_id } => {
498 write!(f, "op {op_id}: stored op_id does not match content")
499 }
500 ChainError::DeviceMismatch { op_id } => {
501 write!(f, "op {op_id}: hlc.device_id != device_id")
502 }
503 ChainError::DuplicateSeq { device_id, seq } => {
504 write!(f, "device {device_id}: duplicate seq {seq}")
505 }
506 ChainError::SeqGap { device_id, expected, found } => {
507 write!(f, "device {device_id}: seq gap (expected {expected}, found {found})")
508 }
509 ChainError::PrevMismatch { op_id } => {
510 write!(f, "op {op_id}: prev does not link to the preceding op")
511 }
512 ChainError::NonMonotonicHlc { op_id } => {
513 write!(f, "op {op_id}: hlc not strictly increasing along device chain")
514 }
515 ChainError::TruncatedChain { device_id, first_seq } => write!(
516 f,
517 "device {device_id}: own chain starts at seq {first_seq} (truncated tail) — \
518 DeviceLog::resume would fork the chain; resume via checkpoint::resume_anchored"
519 ),
520 }
521 }
522}
523
524impl std::error::Error for ChainError {}
525
526/// Verify a log's integrity and order: every id recomputes, and every
527/// device's ops form a contiguous, `prev`-linked, HLC-monotone chain from
528/// the first op present for that device (a checkpointed log need not start
529/// at `seq 0`, but if `seq 0` is present its `prev` must be `None`).
530pub fn verify_log(ops: &[OpRecord]) -> Result<(), ChainError> {
531 let mut by_device: BTreeMap<&str, BTreeMap<u64, &OpRecord>> = BTreeMap::new();
532 for op in ops {
533 if !op.id_valid() {
534 return Err(ChainError::IdMismatch { op_id: op.op_id.clone() });
535 }
536 if op.hlc.device_id != op.device_id {
537 return Err(ChainError::DeviceMismatch { op_id: op.op_id.clone() });
538 }
539 if by_device
540 .entry(&op.device_id)
541 .or_default()
542 .insert(op.seq, op)
543 .is_some()
544 {
545 return Err(ChainError::DuplicateSeq {
546 device_id: op.device_id.clone(),
547 seq: op.seq,
548 });
549 }
550 }
551 for (device_id, chain) in by_device {
552 let mut prev_op: Option<&OpRecord> = None;
553 for (&seq, op) in &chain {
554 match prev_op {
555 None => {
556 if seq == 0 && op.prev.is_some() {
557 return Err(ChainError::PrevMismatch { op_id: op.op_id.clone() });
558 }
559 }
560 Some(previous) => {
561 if seq != previous.seq + 1 {
562 return Err(ChainError::SeqGap {
563 device_id: device_id.to_string(),
564 expected: previous.seq + 1,
565 found: seq,
566 });
567 }
568 if op.prev.as_deref() != Some(previous.op_id.as_str()) {
569 return Err(ChainError::PrevMismatch { op_id: op.op_id.clone() });
570 }
571 if op.hlc <= previous.hlc {
572 return Err(ChainError::NonMonotonicHlc { op_id: op.op_id.clone() });
573 }
574 }
575 }
576 prev_op = Some(op);
577 }
578 }
579 Ok(())
580}
581
582/// The per-device append discipline: maintains the `seq`/`prev` chain and
583/// stamps [`Hlc`] values from an [`HlcClock`] over an injected [`WallClock`]
584/// — B3's real hybrid clock, replacing B1's pure-Lamport stamp source
585/// behind the same wire shape.
586///
587/// [`DeviceLog::new`] defaults the wall source to [`logical_clock`]
588/// (always 0), under which the HLC *is* a Lamport clock (every tick is a
589/// counter increment) — B1 semantics as the degenerate case of one code
590/// path. Real deployments pass [`system_clock`] (or a test-controlled
591/// closure) via [`DeviceLog::with_wall_clock`] / [`DeviceLog::set_wall_clock`].
592#[derive(Clone)]
593pub struct DeviceLog {
594 pub(crate) device_id: String,
595 pub(crate) next_seq: u64,
596 pub(crate) prev: Option<String>,
597 pub(crate) clock: HlcClock,
598 wall: WallClock,
599}
600
601impl fmt::Debug for DeviceLog {
602 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
603 f.debug_struct("DeviceLog")
604 .field("device_id", &self.device_id)
605 .field("next_seq", &self.next_seq)
606 .field("prev", &self.prev)
607 .field("clock", &self.clock)
608 .finish_non_exhaustive() // the wall closure has no useful Debug
609 }
610}
611
612impl DeviceLog {
613 pub fn new(device_id: impl Into<String>) -> Self {
614 Self::with_wall_clock(device_id, logical_clock())
615 }
616
617 /// A device log stamping the real HLC over the given wall source
618 /// (pass [`system_clock`] in production, a controlled closure in tests).
619 pub fn with_wall_clock(device_id: impl Into<String>, wall: WallClock) -> Self {
620 Self {
621 device_id: device_id.into(),
622 next_seq: 0,
623 prev: None,
624 clock: HlcClock::new(),
625 wall,
626 }
627 }
628
629 /// Swap the wall source on a live log (e.g. after a
630 /// [`DeviceLog::resume`], which has no wall parameter). Monotonicity is
631 /// unaffected: the [`HlcClock`] never regresses below what it has
632 /// witnessed, whatever the new source reads.
633 pub fn set_wall_clock(&mut self, wall: WallClock) {
634 self.wall = wall;
635 }
636
637 /// Resume a device's chain from previously persisted ops (e.g. after
638 /// [`crate::journal::OplogJournal::load`]): verifies the log, adopts this
639 /// device's chain tail, and advances the clock past **every** op
640 /// present (local and remote), so new appends stamp above all of them.
641 /// The resumed log defaults to the [`logical_clock`] wall source — call
642 /// [`DeviceLog::set_wall_clock`] to attach the real one.
643 ///
644 /// **MUST: an op is journal-durable before it is transmitted.** Resume
645 /// derives `next_seq` from the journal; if a crash lands between
646 /// "op sent to a peer/relay" and "op durably journaled", the resumed
647 /// device re-mints that `seq` for a *different* op, and the union of the
648 /// two logs is a permanent `DuplicateSeq`/`PrevMismatch` — an
649 /// unrecoverable fork of the device's chain. Always
650 /// `OplogJournal::append` (which flushes) before handing an op to any
651 /// transport (B3 must preserve this ordering).
652 ///
653 /// **Fenced against truncated tails (B4).** If the resuming device's
654 /// own chain doesn't start at `seq 0`, the ops are a truncated tail and
655 /// resume refuses ([`ChainError::TruncatedChain`]) — the anchored
656 /// sibling `checkpoint::resume_anchored` is the correct path. (The case
657 /// this check can't see — a device whose ops were ALL truncated away —
658 /// is fenced one layer down: `OplogJournal::load` refuses a journal
659 /// carrying a truncation marker.)
660 pub fn resume(device_id: impl Into<String>, ops: &[OpRecord]) -> Result<Self, ChainError> {
661 verify_log(ops)?;
662 let device_id = device_id.into();
663 if let Some(first_seq) = ops
664 .iter()
665 .filter(|op| op.device_id == device_id)
666 .map(|op| op.seq)
667 .min()
668 {
669 if first_seq > 0 {
670 return Err(ChainError::TruncatedChain { device_id, first_seq });
671 }
672 }
673 let mut log = Self::new(device_id.clone());
674 for op in ops {
675 log.clock.observe(&op.hlc);
676 if op.device_id == device_id && op.seq >= log.next_seq {
677 log.next_seq = op.seq + 1;
678 log.prev = Some(op.op_id.clone());
679 }
680 }
681 Ok(log)
682 }
683
684 /// HLC receive rule: fold a received op's stamp into the local clock,
685 /// so a write that causally follows received ops stamps above them.
686 pub fn observe(&mut self, hlc: &Hlc) {
687 self.clock.observe(hlc);
688 }
689
690 /// Append a new op: read the wall, tick the hybrid clock, stamp, link
691 /// the chain.
692 pub fn append(&mut self, scope: Scope, surface: Surface, payload: Value) -> OpRecord {
693 let hlc = self.clock.tick((self.wall)(), &self.device_id);
694 let op = OpRecord::new(hlc, self.next_seq, self.prev.take(), scope, surface, payload);
695 self.next_seq += 1;
696 self.prev = Some(op.op_id.clone());
697 op
698 }
699
700 pub fn device_id(&self) -> &str {
701 &self.device_id
702 }
703}
704
705#[cfg(test)]
706mod tests {
707 use super::*;
708 use serde_json::json;
709
710 #[test]
711 fn op_id_is_deterministic_and_content_derived() {
712 let mk = || {
713 OpRecord::new(
714 Hlc { wall_ms: 7, counter: 0, device_id: "d1".into() },
715 0,
716 None,
717 Scope::Personal,
718 Surface::Knowledge,
719 json!({"id": "f1", "body": "x"}),
720 )
721 };
722 let a = mk();
723 let b = mk();
724 assert_eq!(a.op_id, b.op_id, "identical content → identical id");
725 assert!(a.op_id.starts_with("op-"));
726 assert_eq!(a.op_id.len(), 3 + 32);
727 assert!(a.id_valid());
728 }
729
730 #[test]
731 fn op_id_covers_every_field() {
732 let base = OpRecord::new(
733 Hlc { wall_ms: 7, counter: 0, device_id: "d1".into() },
734 1,
735 Some("op-0".into()),
736 Scope::Personal,
737 Surface::Knowledge,
738 json!({"id": "f1"}),
739 );
740 let variants = [
741 OpRecord::new(
742 Hlc { wall_ms: 8, counter: 0, device_id: "d1".into() },
743 1,
744 Some("op-0".into()),
745 Scope::Personal,
746 Surface::Knowledge,
747 json!({"id": "f1"}),
748 ),
749 OpRecord::new(
750 Hlc { wall_ms: 7, counter: 0, device_id: "d1".into() },
751 2,
752 Some("op-0".into()),
753 Scope::Personal,
754 Surface::Knowledge,
755 json!({"id": "f1"}),
756 ),
757 OpRecord::new(
758 Hlc { wall_ms: 7, counter: 0, device_id: "d1".into() },
759 1,
760 Some("op-1".into()),
761 Scope::Personal,
762 Surface::Knowledge,
763 json!({"id": "f1"}),
764 ),
765 OpRecord::new(
766 Hlc { wall_ms: 7, counter: 0, device_id: "d1".into() },
767 1,
768 Some("op-0".into()),
769 Scope::Shared { org: "acme".into() },
770 Surface::Knowledge,
771 json!({"id": "f1"}),
772 ),
773 OpRecord::new(
774 Hlc { wall_ms: 7, counter: 0, device_id: "d1".into() },
775 1,
776 Some("op-0".into()),
777 Scope::Personal,
778 Surface::Skill,
779 json!({"id": "f1"}),
780 ),
781 OpRecord::new(
782 Hlc { wall_ms: 7, counter: 0, device_id: "d1".into() },
783 1,
784 Some("op-0".into()),
785 Scope::Personal,
786 Surface::Knowledge,
787 json!({"id": "f2"}),
788 ),
789 ];
790 for v in &variants {
791 assert_ne!(base.op_id, v.op_id, "changing any field changes the id");
792 }
793 }
794
795 #[test]
796 fn canonical_json_is_key_order_independent() {
797 // parse two spellings of the same object
798 let a: Value = serde_json::from_str(r#"{"b":1,"a":{"y":2,"x":3}}"#).unwrap();
799 let b: Value = serde_json::from_str(r#"{"a":{"x":3,"y":2},"b":1}"#).unwrap();
800 assert_eq!(canonical_json(&a), canonical_json(&b));
801 assert_eq!(canonical_json(&a), r#"{"a":{"x":3,"y":2},"b":1}"#);
802 }
803
804 #[test]
805 fn surface_tags_and_tiers_are_exhaustive() {
806 let surfaces = [
807 (Surface::Routing, "routing", FoldTier::GrowOnly),
808 (Surface::Declagent, "declagent", FoldTier::Registry),
809 (Surface::Conversation, "conversation", FoldTier::GrowOnly),
810 (Surface::Knowledge, "knowledge", FoldTier::GrowOnly),
811 (Surface::Skill, "skill", FoldTier::GrowOnly),
812 (Surface::Registry { kind: "agents".into() }, "registry:agents", FoldTier::Registry),
813 (Surface::Trajectory, "trajectory", FoldTier::GrowOnly),
814 (Surface::Run, "run", FoldTier::GrowOnly),
815 (Surface::Intent, "intent", FoldTier::Leased),
816 ];
817 for (s, tag, tier) in surfaces {
818 assert_eq!(s.tag(), tag);
819 assert_eq!(s.fold_tier(), tier);
820 // Intent is a logical-entity ledger, not an observation multiset.
821 assert!(!Surface::Intent.is_event_stream());
822 }
823 // Event streams (op_id-keyed multisets): routing AND conversation (B2 —
824 // op identity is turn identity). Only routing is a path-dependent
825 // REPLAY stream (retention-forbidden); conversation is an independent
826 // multiset that tolerates LastN.
827 assert!(Surface::Routing.is_event_stream() && Surface::Routing.is_replay_stream());
828 assert!(Surface::Conversation.is_event_stream());
829 assert!(!Surface::Conversation.is_replay_stream());
830 assert!(!Surface::Knowledge.is_event_stream());
831 }
832
833 #[test]
834 fn tampering_is_detected() {
835 let mut log = DeviceLog::new("d1");
836 let mut ops = vec![
837 log.append(Scope::Personal, Surface::Knowledge, json!({"id": "f1"})),
838 log.append(Scope::Personal, Surface::Knowledge, json!({"id": "f2"})),
839 ];
840 verify_log(&ops).unwrap();
841 // Mutate a payload without recomputing the id.
842 ops[1].payload = json!({"id": "f2", "body": "forged"});
843 assert!(matches!(verify_log(&ops), Err(ChainError::IdMismatch { .. })));
844 }
845
846 #[test]
847 fn chain_defects_are_detected() {
848 let mut log = DeviceLog::new("d1");
849 let o0 = log.append(Scope::Personal, Surface::Knowledge, json!({"id": "a"}));
850 let o1 = log.append(Scope::Personal, Surface::Knowledge, json!({"id": "b"}));
851 let o2 = log.append(Scope::Personal, Surface::Knowledge, json!({"id": "c"}));
852 verify_log(&[o0.clone(), o1.clone(), o2.clone()]).unwrap();
853
854 // Missing middle op → seq gap.
855 assert!(matches!(
856 verify_log(&[o0.clone(), o2.clone()]),
857 Err(ChainError::SeqGap { expected: 1, found: 2, .. })
858 ));
859
860 // prev link forged (re-id'd so IdMismatch doesn't fire first).
861 let forged = OpRecord::new(
862 o1.hlc.clone(),
863 o1.seq,
864 Some(o2.op_id.clone()), // wrong parent
865 o1.scope.clone(),
866 o1.surface.clone(),
867 o1.payload.clone(),
868 );
869 assert!(matches!(
870 verify_log(&[o0.clone(), forged, o2.clone()]),
871 Err(ChainError::PrevMismatch { .. })
872 ));
873
874 // hlc going backwards along the chain.
875 let backwards = OpRecord::new(
876 Hlc { wall_ms: 0, counter: 0, device_id: "d1".into() },
877 o1.seq,
878 Some(o0.op_id.clone()),
879 o1.scope.clone(),
880 o1.surface.clone(),
881 o1.payload.clone(),
882 );
883 assert!(matches!(
884 verify_log(&[o0.clone(), backwards]),
885 Err(ChainError::NonMonotonicHlc { .. })
886 ));
887
888 // seq 0 with a parent.
889 let rooted = OpRecord::new(
890 o0.hlc.clone(),
891 0,
892 Some(o2.op_id.clone()),
893 o0.scope.clone(),
894 o0.surface.clone(),
895 o0.payload.clone(),
896 );
897 assert!(matches!(verify_log(&[rooted]), Err(ChainError::PrevMismatch { .. })));
898
899 // duplicate seq.
900 let dup = OpRecord::new(
901 Hlc { wall_ms: 99, counter: 0, device_id: "d1".into() },
902 o1.seq,
903 Some(o0.op_id.clone()),
904 o1.scope.clone(),
905 o1.surface.clone(),
906 json!({"id": "dup"}),
907 );
908 assert!(matches!(
909 verify_log(&[o0.clone(), o1.clone(), dup]),
910 Err(ChainError::DuplicateSeq { seq: 1, .. })
911 ));
912
913 // hlc.device_id disagreeing with device_id.
914 let mut cross = o0.clone();
915 cross.hlc.device_id = "d2".into();
916 cross.op_id = cross.compute_op_id();
917 assert!(matches!(verify_log(&[cross]), Err(ChainError::DeviceMismatch { .. })));
918 }
919
920 #[test]
921 fn observe_advances_clock_past_received_ops() {
922 let mut a = DeviceLog::new("a");
923 let mut b = DeviceLog::new("b");
924 let oa = a.append(Scope::Personal, Surface::Knowledge, json!({"id": "x"}));
925 // Degenerate (logical_clock) mode: pure Lamport in the counter,
926 // wall component pinned at 0 — B1's order semantics, same wire shape.
927 assert_eq!(oa.hlc, Hlc { wall_ms: 0, counter: 1, device_id: "a".into() });
928 b.observe(&oa.hlc);
929 let ob = b.append(Scope::Personal, Surface::Knowledge, json!({"id": "y"}));
930 assert!(ob.hlc > oa.hlc, "causally-later write stamps above the observed op");
931 }
932
933 /// A test wall clock the test advances (or regresses) by hand.
934 fn manual_clock() -> (std::sync::Arc<std::sync::atomic::AtomicU64>, WallClock) {
935 let t = std::sync::Arc::new(std::sync::atomic::AtomicU64::new(0));
936 let reader = t.clone();
937 let wall: WallClock =
938 Arc::new(move || reader.load(std::sync::atomic::Ordering::SeqCst));
939 (t, wall)
940 }
941
942 #[test]
943 fn hlc_is_monotone_under_wall_clock_regression() {
944 use std::sync::atomic::Ordering;
945 let (t, wall) = manual_clock();
946 let mut dev = DeviceLog::with_wall_clock("d1", wall);
947 t.store(100, Ordering::SeqCst);
948 let o1 = dev.append(Scope::Personal, Surface::Knowledge, json!({"id": "a"}));
949 assert_eq!((o1.hlc.wall_ms, o1.hlc.counter), (100, 0));
950
951 // The wall clock jumps BACKWARDS (NTP step, VM restore): stamps keep
952 // strictly increasing on the counter, wall pinned at the max seen.
953 t.store(40, Ordering::SeqCst);
954 let o2 = dev.append(Scope::Personal, Surface::Knowledge, json!({"id": "b"}));
955 let o3 = dev.append(Scope::Personal, Surface::Knowledge, json!({"id": "c"}));
956 assert_eq!((o2.hlc.wall_ms, o2.hlc.counter), (100, 1));
957 assert_eq!((o3.hlc.wall_ms, o3.hlc.counter), (100, 2));
958 assert!(o1.hlc < o2.hlc && o2.hlc < o3.hlc);
959
960 // The wall recovers past the pinned max: counter resets.
961 t.store(200, Ordering::SeqCst);
962 let o4 = dev.append(Scope::Personal, Surface::Knowledge, json!({"id": "d"}));
963 assert_eq!((o4.hlc.wall_ms, o4.hlc.counter), (200, 0));
964 verify_log(&[o1, o2, o3, o4]).expect("regression-spanning chain stays HLC-monotone");
965 }
966
967 #[test]
968 fn hlc_burst_within_one_millisecond_stays_strictly_ordered() {
969 use std::sync::atomic::Ordering;
970 let (t, wall) = manual_clock();
971 let mut dev = DeviceLog::with_wall_clock("d1", wall);
972 t.store(555, Ordering::SeqCst);
973 let ops: Vec<OpRecord> = (0..50)
974 .map(|i| dev.append(Scope::Personal, Surface::Routing, json!({"n": i})))
975 .collect();
976 for (i, op) in ops.iter().enumerate() {
977 assert_eq!(op.hlc.wall_ms, 555);
978 assert_eq!(op.hlc.counter, i as u32, "burst rides the counter");
979 }
980 verify_log(&ops).unwrap();
981 }
982
983 #[test]
984 fn hlc_absorbs_skewed_peer_stamps_and_preserves_causality() {
985 use std::sync::atomic::Ordering;
986 // Device b's wall clock runs far BEHIND device a's (skew), yet a
987 // write on b that causally follows a's op must stamp above it.
988 let (ta, wall_a) = manual_clock();
989 let (tb, wall_b) = manual_clock();
990 let mut a = DeviceLog::with_wall_clock("a", wall_a);
991 let mut b = DeviceLog::with_wall_clock("b", wall_b);
992 ta.store(10_000, Ordering::SeqCst);
993 tb.store(3, Ordering::SeqCst); // b is ~10s behind
994
995 let oa = a.append(Scope::Personal, Surface::Knowledge, json!({"id": "x"}));
996 b.observe(&oa.hlc); // receive rule: absorb the future stamp
997 let ob = b.append(Scope::Personal, Surface::Knowledge, json!({"id": "y"}));
998 assert!(ob.hlc > oa.hlc, "causality survives a 10s skew");
999 assert_eq!(ob.hlc.wall_ms, 10_000, "wall pinned at the max witnessed, not b's slow clock");
1000 assert_eq!(ob.hlc.counter, 1);
1001
1002 // …and once b's wall genuinely passes the witnessed max, the wall
1003 // component takes over again (the 'hybrid' half).
1004 tb.store(20_000, Ordering::SeqCst);
1005 let ob2 = b.append(Scope::Personal, Surface::Knowledge, json!({"id": "z"}));
1006 assert_eq!((ob2.hlc.wall_ms, ob2.hlc.counter), (20_000, 0));
1007 verify_log(&[ob, ob2]).unwrap();
1008 }
1009
1010 #[test]
1011 fn hlc_wire_shape_is_unchanged_from_b1() {
1012 // The B1→B3 promise: swapping the stamp source changes no wire bytes.
1013 let hlc = Hlc { wall_ms: 7, counter: 2, device_id: "d1".into() };
1014 assert_eq!(
1015 serde_json::to_value(&hlc).unwrap(),
1016 json!({"wall_ms": 7, "counter": 2, "device_id": "d1"})
1017 );
1018 }
1019
1020 #[test]
1021 fn resume_adopts_witnessed_stamps_under_a_real_clock() {
1022 use std::sync::atomic::Ordering;
1023 let (t, wall) = manual_clock();
1024 t.store(500, Ordering::SeqCst);
1025 let mut dev = DeviceLog::with_wall_clock("d1", wall.clone());
1026 let ops = vec![
1027 dev.append(Scope::Personal, Surface::Knowledge, json!({"id": "a"})),
1028 dev.append(Scope::Personal, Surface::Knowledge, json!({"id": "b"})),
1029 ];
1030 // Restart: resume from the journal, re-attach the (now regressed)
1031 // wall — the next stamp still lands above everything persisted.
1032 t.store(100, Ordering::SeqCst);
1033 let mut resumed = DeviceLog::resume("d1", &ops).unwrap();
1034 resumed.set_wall_clock(wall);
1035 let next = resumed.append(Scope::Personal, Surface::Knowledge, json!({"id": "c"}));
1036 assert!(next.hlc > ops[1].hlc);
1037 let mut all = ops;
1038 all.push(next);
1039 verify_log(&all).unwrap();
1040 }
1041
1042 #[test]
1043 fn resume_continues_the_chain() {
1044 let mut log = DeviceLog::new("d1");
1045 let mut peer = DeviceLog::new("d2");
1046 let ops = vec![
1047 log.append(Scope::Personal, Surface::Knowledge, json!({"id": "a"})),
1048 log.append(Scope::Personal, Surface::Knowledge, json!({"id": "b"})),
1049 peer.append(Scope::Personal, Surface::Knowledge, json!({"id": "c"})),
1050 ];
1051 let mut resumed = DeviceLog::resume("d1", &ops).unwrap();
1052 let next = resumed.append(Scope::Personal, Surface::Knowledge, json!({"id": "d"}));
1053 assert_eq!(next.seq, 2);
1054 assert_eq!(next.prev.as_deref(), Some(ops[1].op_id.as_str()));
1055 let mut all = ops;
1056 all.push(next);
1057 verify_log(&all).unwrap();
1058 }
1059
1060 #[test]
1061 fn stable_key_uses_payload_id_else_content_hash() {
1062 let mut log = DeviceLog::new("d1");
1063 let with_id = log.append(Scope::Personal, Surface::Knowledge, json!({"id": "f1", "v": 1}));
1064 assert_eq!(with_id.stable_key(), "id:f1");
1065 // `stable_key` is the logical-ENTITY key (knowledge/skills/registries):
1066 // an id-less payload keys on its canonical content hash, key-order
1067 // independent, so the same fact emitted by two devices dedups.
1068 // (Conversation is an event stream — B2 — so it does NOT use stable_key
1069 // in the fold; it keys on op_id. See fold_key / is_event_stream.)
1070 let anon1 = log.append(
1071 Scope::Personal,
1072 Surface::Knowledge,
1073 json!({"kind": "note", "body": "hi"}),
1074 );
1075 let anon2 = OpRecord::new(
1076 Hlc { wall_ms: 42, counter: 0, device_id: "d2".into() },
1077 0,
1078 None,
1079 Scope::Personal,
1080 Surface::Knowledge,
1081 json!({"body": "hi", "kind": "note"}),
1082 );
1083 assert_eq!(anon1.stable_key(), anon2.stable_key());
1084 assert!(anon1.stable_key().starts_with("h:"));
1085 }
1086
1087 #[test]
1088 fn op_record_serde_round_trips() {
1089 let mut log = DeviceLog::new("d1");
1090 let op = log.append(
1091 Scope::Shared { org: "acme".into() },
1092 Surface::Registry { kind: "agents".into() },
1093 json!({"id": "agent-1"}),
1094 );
1095 let json = serde_json::to_string(&op).unwrap();
1096 let back: OpRecord = serde_json::from_str(&json).unwrap();
1097 assert_eq!(back, op);
1098 assert!(back.id_valid());
1099 }
1100}