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 {
207 kind: String,
208 },
209 Trajectory,
210 Run,
211 /// Leased execution-intent ledger (B5): payload
212 /// `{id: run_id, agent_id, epoch, status}`. Folds under
213 /// [`FoldTier::Leased`] — LWW-per-run_id with monotone status **plus
214 /// per-agent epoch fencing**, so a stale-epoch write from a failed-over
215 /// lease holder loses deterministically at the fold. See [`crate::lease`].
216 Intent,
217}
218
219/// How a surface folds — the proposal's per-surface fold-rule tiers.
220#[derive(Debug, Clone, Copy, PartialEq, Eq)]
221pub enum FoldTier {
222 /// Union by stable ID (conversations, knowledge, skills, trajectories,
223 /// runs, routing *observations* — the grow-only tier).
224 GrowOnly,
225 /// LWW-register per record keyed by id, ordered by HLC (declagents and
226 /// the file registries) — NOT per file.
227 Registry,
228 /// Leased execution-intent tier (`Intent`, B5): LWW-per-run_id with
229 /// monotone status, **plus epoch fencing** — an intent whose `epoch` is
230 /// below its agent's max-seen epoch is dropped at the fold (a fenced
231 /// zombie writer after a lease failover loses deterministically, with no
232 /// wall-clock race). NOT grow-only. See [`crate::lease`]/[`crate::fold`](mod@crate::fold).
233 Leased,
234}
235
236impl Surface {
237 /// Stable string form: the fold's grouping key and part of the `op_id`
238 /// digest.
239 pub fn tag(&self) -> String {
240 match self {
241 Surface::Routing => "routing".to_string(),
242 Surface::Declagent => "declagent".to_string(),
243 Surface::Conversation => "conversation".to_string(),
244 Surface::Knowledge => "knowledge".to_string(),
245 Surface::Skill => "skill".to_string(),
246 Surface::Registry { kind } => format!("registry:{kind}"),
247 Surface::Trajectory => "trajectory".to_string(),
248 Surface::Run => "run".to_string(),
249 // FROZEN (B5): part of the op_id digest — never change this string.
250 Surface::Intent => "intent".to_string(),
251 }
252 }
253
254 /// The proposal's fold-rule table. Routing observations are grow-only
255 /// log entries ("sync the observations, not the result"); the EMA replay
256 /// over them is the caller-injected [`crate::fold::SyncState::replay`].
257 pub fn fold_tier(&self) -> FoldTier {
258 match self {
259 Surface::Conversation
260 | Surface::Knowledge
261 | Surface::Skill
262 | Surface::Trajectory
263 | Surface::Run
264 | Surface::Routing => FoldTier::GrowOnly,
265 Surface::Declagent | Surface::Registry { .. } => FoldTier::Registry,
266 Surface::Intent => FoldTier::Leased,
267 }
268 }
269
270 /// Is this surface an **event stream** — a multiset keyed by `op_id`
271 /// rather than a set of content-deduped logical entities?
272 ///
273 /// Two surfaces are event streams, for the same structural reason but with
274 /// different downstream handling:
275 ///
276 /// - **Routing** — the proposal's "replays the merged **multiset** of
277 /// observations": `agent x succeeded` twice is two events that must both
278 /// reach the EMA replay.
279 /// - **Conversation** (B2, kernel-review correction) — a conversation turn
280 /// has exactly one author and propagates by op replication, so **op
281 /// identity IS turn identity**. Content-keying was a reproduced
282 /// data-loss bug: two genuine "yes" turns stamped at the same
283 /// payload-second (cached `now()`, rapid double-confirm) fold to one
284 /// entry. Keyed by `op_id`, a *resent* op dedups but two *distinct*
285 /// authorings never collapse. Unlike routing, conversation turns are
286 /// **independent** entries (no path-dependent replay), so they tolerate
287 /// `LastN` retention — see [`Surface::is_replay_stream`].
288 ///
289 /// Event-stream surfaces fold keyed by `op_id` — see [`OpRecord::fold_key`].
290 pub fn is_event_stream(&self) -> bool {
291 match self {
292 Surface::Routing | Surface::Conversation => true,
293 Surface::Declagent
294 | Surface::Knowledge
295 | Surface::Skill
296 | Surface::Registry { .. }
297 | Surface::Trajectory
298 | Surface::Run
299 | Surface::Intent => false,
300 }
301 }
302
303 /// Is this surface a **path-dependent replay** stream — one whose folded
304 /// result is recomputed from the ordered multiset (routing's EMA), so that
305 /// dropping ANY entry corrupts every device's recomputed value? Only these
306 /// are retention-forbidden (`compact` rejects any non-keep-all rule on
307 /// them). This is **narrower than [`Surface::is_event_stream`]**:
308 /// conversation is an event-stream multiset too, but its turns are
309 /// independent, so `LastN` over them is well-defined and allowed. Routing
310 /// is the only replay stream today; a new one is a compile-error here (no
311 /// `_` arm), the intended review gate.
312 pub fn is_replay_stream(&self) -> bool {
313 match self {
314 Surface::Routing => true,
315 Surface::Declagent
316 | Surface::Conversation
317 | Surface::Knowledge
318 | Surface::Skill
319 | Surface::Registry { .. }
320 | Surface::Trajectory
321 | Surface::Run
322 | Surface::Intent => false,
323 }
324 }
325}
326
327/// One state-changing operation in the oplog.
328#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
329pub struct OpRecord {
330 /// Content-derived id (see module docs): dedup key for retransmission
331 /// and tamper-evident cover of the whole record.
332 pub op_id: String,
333 /// Total-order stamp. Invariant: `hlc.device_id == device_id`.
334 pub hlc: Hlc,
335 /// The device (replica) that emitted the op. Matches the `replica`
336 /// strings `car_state::crdt` already uses.
337 pub device_id: String,
338 /// Per-device append index (0-based, contiguous).
339 pub seq: u64,
340 /// `op_id` of this device's previous op (`None` iff `seq == 0`) — the
341 /// per-device hash-chain link that makes the log order-verifiable.
342 pub prev: Option<String>,
343 pub scope: Scope,
344 pub surface: Surface,
345 /// Surface-specific payload (possibly E2E ciphertext in B6).
346 pub payload: Value,
347}
348
349/// Canonical, key-sorted, compact JSON — the deterministic serialization the
350/// `op_id` digest and [`crate::fold::state_hash`] are computed over.
351/// Independent of `serde_json`'s map-ordering configuration.
352pub fn canonical_json(v: &Value) -> String {
353 match v {
354 Value::Object(map) => {
355 let mut keys: Vec<&String> = map.keys().collect();
356 keys.sort();
357 let inner: Vec<String> = keys
358 .iter()
359 .map(|k| {
360 format!(
361 "{}:{}",
362 serde_json::to_string(k).expect("string serializes"),
363 canonical_json(&map[k.as_str()])
364 )
365 })
366 .collect();
367 format!("{{{}}}", inner.join(","))
368 }
369 Value::Array(items) => {
370 let inner: Vec<String> = items.iter().map(canonical_json).collect();
371 format!("[{}]", inner.join(","))
372 }
373 _ => serde_json::to_string(v).unwrap_or_default(),
374 }
375}
376
377// NOTE: this reimplements car-proto's B7 content-address discipline
378// (`deterministic_run_id`: SHA-256, 0x1f separators, 16-byte/32-hex prefix)
379// rather than depending on car-proto, which would drag the whole protocol
380// crate into this dependency-light core. Consolidating the discipline into a
381// shared home (car-proto exporting just the hasher, or a tiny common crate)
382// is a next-slice cleanup — keep the two in step until then.
383fn sha256_hex_32(fields: &[&str]) -> String {
384 let mut hasher = Sha256::new();
385 for (i, f) in fields.iter().enumerate() {
386 if i > 0 {
387 hasher.update(b"\x1f"); // B7's field separator — no concat collisions
388 }
389 hasher.update(f.as_bytes());
390 }
391 let digest = hasher.finalize();
392 digest.iter().take(16).map(|b| format!("{b:02x}")).collect()
393}
394
395impl OpRecord {
396 /// Build an op and stamp its content-derived id. Callers normally go
397 /// through [`DeviceLog::append`], which manages `seq`/`prev`/`hlc`.
398 pub fn new(
399 hlc: Hlc,
400 seq: u64,
401 prev: Option<String>,
402 scope: Scope,
403 surface: Surface,
404 payload: Value,
405 ) -> Self {
406 let device_id = hlc.device_id.clone();
407 let mut op = OpRecord {
408 op_id: String::new(),
409 hlc,
410 device_id,
411 seq,
412 prev,
413 scope,
414 surface,
415 payload,
416 };
417 op.op_id = op.compute_op_id();
418 op
419 }
420
421 /// Recompute the content-derived id from the record's fields (the B7
422 /// SHA-256 + `0x1f` discipline; `op-` + 32 hex chars).
423 pub fn compute_op_id(&self) -> String {
424 let hex = sha256_hex_32(&[
425 &self.device_id,
426 &self.seq.to_string(),
427 self.prev.as_deref().unwrap_or(""),
428 &self.hlc.wall_ms.to_string(),
429 &self.hlc.counter.to_string(),
430 &self.hlc.device_id,
431 &self.scope.tag(),
432 &self.surface.tag(),
433 &canonical_json(&self.payload),
434 ]);
435 format!("op-{hex}")
436 }
437
438 /// Does the stored id match the record's content?
439 pub fn id_valid(&self) -> bool {
440 self.op_id == self.compute_op_id()
441 }
442
443 /// The stable key the fold dedups logical entities on: the payload's
444 /// `"id"` string when present (the proposal's `fact_id` / record-id
445 /// keys), else the canonical content hash (which realizes e.g. the
446 /// conversation `(speaker,text,timestamp)` dedup — identical content is
447 /// one entity). Prefixed so the two forms can never collide.
448 ///
449 /// NOT used for event-stream surfaces (routing) — the fold keys those by
450 /// `op_id` via [`OpRecord::fold_key`], because an event stream is a
451 /// multiset: identical content is two events, not one entity.
452 pub fn stable_key(&self) -> String {
453 match self.payload.get("id").and_then(Value::as_str) {
454 Some(id) => format!("id:{id}"),
455 None => format!("h:{}", sha256_hex_32(&[&canonical_json(&self.payload)])),
456 }
457 }
458
459 /// The key the fold stores this op under: [`OpRecord::stable_key`] for
460 /// logical-entity surfaces, `op_id` for event-stream surfaces
461 /// ([`Surface::is_event_stream`] — the proposal's routing MULTISET:
462 /// every emitted observation survives the fold; only retransmission of
463 /// the *same* op dedups). Prefixes keep the three key forms (`id:`,
464 /// `h:`, `op:`) disjoint.
465 pub fn fold_key(&self) -> String {
466 if self.surface.is_event_stream() {
467 format!("op:{}", self.op_id)
468 } else {
469 self.stable_key()
470 }
471 }
472}
473
474/// A chain-verification failure from [`verify_log`].
475#[derive(Debug, Clone, PartialEq, Eq)]
476pub enum ChainError {
477 /// A record's stored `op_id` doesn't match its content.
478 IdMismatch { op_id: String },
479 /// `hlc.device_id` disagrees with the record's `device_id`.
480 DeviceMismatch { op_id: String },
481 /// Two ops from one device claim the same `seq`.
482 DuplicateSeq { device_id: String, seq: u64 },
483 /// A device's seqs aren't contiguous from its first present op.
484 SeqGap {
485 device_id: String,
486 expected: u64,
487 found: u64,
488 },
489 /// `prev` doesn't link to the device's preceding op (or `seq 0` has one).
490 PrevMismatch { op_id: String },
491 /// A device's HLC stamps aren't strictly increasing along its chain.
492 NonMonotonicHlc { op_id: String },
493 /// [`DeviceLog::resume`] was handed a log in which the resuming
494 /// device's own chain doesn't start at `seq 0` — a truncated tail.
495 /// Resuming from it would re-mint truncated seqs (a permanent chain
496 /// fork); go through `checkpoint::resume_anchored` instead.
497 TruncatedChain { device_id: String, first_seq: u64 },
498}
499
500impl fmt::Display for ChainError {
501 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
502 match self {
503 ChainError::IdMismatch { op_id } => {
504 write!(f, "op {op_id}: stored op_id does not match content")
505 }
506 ChainError::DeviceMismatch { op_id } => {
507 write!(f, "op {op_id}: hlc.device_id != device_id")
508 }
509 ChainError::DuplicateSeq { device_id, seq } => {
510 write!(f, "device {device_id}: duplicate seq {seq}")
511 }
512 ChainError::SeqGap {
513 device_id,
514 expected,
515 found,
516 } => {
517 write!(
518 f,
519 "device {device_id}: seq gap (expected {expected}, found {found})"
520 )
521 }
522 ChainError::PrevMismatch { op_id } => {
523 write!(f, "op {op_id}: prev does not link to the preceding op")
524 }
525 ChainError::NonMonotonicHlc { op_id } => {
526 write!(
527 f,
528 "op {op_id}: hlc not strictly increasing along device chain"
529 )
530 }
531 ChainError::TruncatedChain {
532 device_id,
533 first_seq,
534 } => write!(
535 f,
536 "device {device_id}: own chain starts at seq {first_seq} (truncated tail) — \
537 DeviceLog::resume would fork the chain; resume via checkpoint::resume_anchored"
538 ),
539 }
540 }
541}
542
543impl std::error::Error for ChainError {}
544
545/// Verify a log's integrity and order: every id recomputes, and every
546/// device's ops form a contiguous, `prev`-linked, HLC-monotone chain from
547/// the first op present for that device (a checkpointed log need not start
548/// at `seq 0`, but if `seq 0` is present its `prev` must be `None`).
549pub fn verify_log(ops: &[OpRecord]) -> Result<(), ChainError> {
550 let mut by_device: BTreeMap<&str, BTreeMap<u64, &OpRecord>> = BTreeMap::new();
551 for op in ops {
552 if !op.id_valid() {
553 return Err(ChainError::IdMismatch {
554 op_id: op.op_id.clone(),
555 });
556 }
557 if op.hlc.device_id != op.device_id {
558 return Err(ChainError::DeviceMismatch {
559 op_id: op.op_id.clone(),
560 });
561 }
562 if by_device
563 .entry(&op.device_id)
564 .or_default()
565 .insert(op.seq, op)
566 .is_some()
567 {
568 return Err(ChainError::DuplicateSeq {
569 device_id: op.device_id.clone(),
570 seq: op.seq,
571 });
572 }
573 }
574 for (device_id, chain) in by_device {
575 let mut prev_op: Option<&OpRecord> = None;
576 for (&seq, op) in &chain {
577 match prev_op {
578 None => {
579 if seq == 0 && op.prev.is_some() {
580 return Err(ChainError::PrevMismatch {
581 op_id: op.op_id.clone(),
582 });
583 }
584 }
585 Some(previous) => {
586 if seq != previous.seq + 1 {
587 return Err(ChainError::SeqGap {
588 device_id: device_id.to_string(),
589 expected: previous.seq + 1,
590 found: seq,
591 });
592 }
593 if op.prev.as_deref() != Some(previous.op_id.as_str()) {
594 return Err(ChainError::PrevMismatch {
595 op_id: op.op_id.clone(),
596 });
597 }
598 if op.hlc <= previous.hlc {
599 return Err(ChainError::NonMonotonicHlc {
600 op_id: op.op_id.clone(),
601 });
602 }
603 }
604 }
605 prev_op = Some(op);
606 }
607 }
608 Ok(())
609}
610
611/// The per-device append discipline: maintains the `seq`/`prev` chain and
612/// stamps [`Hlc`] values from an [`HlcClock`] over an injected [`WallClock`]
613/// — B3's real hybrid clock, replacing B1's pure-Lamport stamp source
614/// behind the same wire shape.
615///
616/// [`DeviceLog::new`] defaults the wall source to [`logical_clock`]
617/// (always 0), under which the HLC *is* a Lamport clock (every tick is a
618/// counter increment) — B1 semantics as the degenerate case of one code
619/// path. Real deployments pass [`system_clock`] (or a test-controlled
620/// closure) via [`DeviceLog::with_wall_clock`] / [`DeviceLog::set_wall_clock`].
621#[derive(Clone)]
622pub struct DeviceLog {
623 pub(crate) device_id: String,
624 pub(crate) next_seq: u64,
625 pub(crate) prev: Option<String>,
626 pub(crate) clock: HlcClock,
627 wall: WallClock,
628}
629
630impl fmt::Debug for DeviceLog {
631 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
632 f.debug_struct("DeviceLog")
633 .field("device_id", &self.device_id)
634 .field("next_seq", &self.next_seq)
635 .field("prev", &self.prev)
636 .field("clock", &self.clock)
637 .finish_non_exhaustive() // the wall closure has no useful Debug
638 }
639}
640
641impl DeviceLog {
642 pub fn new(device_id: impl Into<String>) -> Self {
643 Self::with_wall_clock(device_id, logical_clock())
644 }
645
646 /// A device log stamping the real HLC over the given wall source
647 /// (pass [`system_clock`] in production, a controlled closure in tests).
648 pub fn with_wall_clock(device_id: impl Into<String>, wall: WallClock) -> Self {
649 Self {
650 device_id: device_id.into(),
651 next_seq: 0,
652 prev: None,
653 clock: HlcClock::new(),
654 wall,
655 }
656 }
657
658 /// Swap the wall source on a live log (e.g. after a
659 /// [`DeviceLog::resume`], which has no wall parameter). Monotonicity is
660 /// unaffected: the [`HlcClock`] never regresses below what it has
661 /// witnessed, whatever the new source reads.
662 pub fn set_wall_clock(&mut self, wall: WallClock) {
663 self.wall = wall;
664 }
665
666 /// Resume a device's chain from previously persisted ops (e.g. after
667 /// [`crate::journal::OplogJournal::load`]): verifies the log, adopts this
668 /// device's chain tail, and advances the clock past **every** op
669 /// present (local and remote), so new appends stamp above all of them.
670 /// The resumed log defaults to the [`logical_clock`] wall source — call
671 /// [`DeviceLog::set_wall_clock`] to attach the real one.
672 ///
673 /// **MUST: an op is journal-durable before it is transmitted.** Resume
674 /// derives `next_seq` from the journal; if a crash lands between
675 /// "op sent to a peer/relay" and "op durably journaled", the resumed
676 /// device re-mints that `seq` for a *different* op, and the union of the
677 /// two logs is a permanent `DuplicateSeq`/`PrevMismatch` — an
678 /// unrecoverable fork of the device's chain. Always
679 /// `OplogJournal::append` (which flushes) before handing an op to any
680 /// transport (B3 must preserve this ordering).
681 ///
682 /// **Fenced against truncated tails (B4).** If the resuming device's
683 /// own chain doesn't start at `seq 0`, the ops are a truncated tail and
684 /// resume refuses ([`ChainError::TruncatedChain`]) — the anchored
685 /// sibling `checkpoint::resume_anchored` is the correct path. (The case
686 /// this check can't see — a device whose ops were ALL truncated away —
687 /// is fenced one layer down: `OplogJournal::load` refuses a journal
688 /// carrying a truncation marker.)
689 pub fn resume(device_id: impl Into<String>, ops: &[OpRecord]) -> Result<Self, ChainError> {
690 verify_log(ops)?;
691 let device_id = device_id.into();
692 if let Some(first_seq) = ops
693 .iter()
694 .filter(|op| op.device_id == device_id)
695 .map(|op| op.seq)
696 .min()
697 {
698 if first_seq > 0 {
699 return Err(ChainError::TruncatedChain {
700 device_id,
701 first_seq,
702 });
703 }
704 }
705 let mut log = Self::new(device_id.clone());
706 for op in ops {
707 log.clock.observe(&op.hlc);
708 if op.device_id == device_id && op.seq >= log.next_seq {
709 log.next_seq = op.seq + 1;
710 log.prev = Some(op.op_id.clone());
711 }
712 }
713 Ok(log)
714 }
715
716 /// HLC receive rule: fold a received op's stamp into the local clock,
717 /// so a write that causally follows received ops stamps above them.
718 pub fn observe(&mut self, hlc: &Hlc) {
719 self.clock.observe(hlc);
720 }
721
722 /// Append a new op: read the wall, tick the hybrid clock, stamp, link
723 /// the chain.
724 pub fn append(&mut self, scope: Scope, surface: Surface, payload: Value) -> OpRecord {
725 let hlc = self.clock.tick((self.wall)(), &self.device_id);
726 let op = OpRecord::new(
727 hlc,
728 self.next_seq,
729 self.prev.take(),
730 scope,
731 surface,
732 payload,
733 );
734 self.next_seq += 1;
735 self.prev = Some(op.op_id.clone());
736 op
737 }
738
739 pub fn device_id(&self) -> &str {
740 &self.device_id
741 }
742}
743
744#[cfg(test)]
745mod tests {
746 use super::*;
747 use serde_json::json;
748
749 #[test]
750 fn op_id_is_deterministic_and_content_derived() {
751 let mk = || {
752 OpRecord::new(
753 Hlc {
754 wall_ms: 7,
755 counter: 0,
756 device_id: "d1".into(),
757 },
758 0,
759 None,
760 Scope::Personal,
761 Surface::Knowledge,
762 json!({"id": "f1", "body": "x"}),
763 )
764 };
765 let a = mk();
766 let b = mk();
767 assert_eq!(a.op_id, b.op_id, "identical content → identical id");
768 assert!(a.op_id.starts_with("op-"));
769 assert_eq!(a.op_id.len(), 3 + 32);
770 assert!(a.id_valid());
771 }
772
773 #[test]
774 fn op_id_covers_every_field() {
775 let base = OpRecord::new(
776 Hlc {
777 wall_ms: 7,
778 counter: 0,
779 device_id: "d1".into(),
780 },
781 1,
782 Some("op-0".into()),
783 Scope::Personal,
784 Surface::Knowledge,
785 json!({"id": "f1"}),
786 );
787 let variants = [
788 OpRecord::new(
789 Hlc {
790 wall_ms: 8,
791 counter: 0,
792 device_id: "d1".into(),
793 },
794 1,
795 Some("op-0".into()),
796 Scope::Personal,
797 Surface::Knowledge,
798 json!({"id": "f1"}),
799 ),
800 OpRecord::new(
801 Hlc {
802 wall_ms: 7,
803 counter: 0,
804 device_id: "d1".into(),
805 },
806 2,
807 Some("op-0".into()),
808 Scope::Personal,
809 Surface::Knowledge,
810 json!({"id": "f1"}),
811 ),
812 OpRecord::new(
813 Hlc {
814 wall_ms: 7,
815 counter: 0,
816 device_id: "d1".into(),
817 },
818 1,
819 Some("op-1".into()),
820 Scope::Personal,
821 Surface::Knowledge,
822 json!({"id": "f1"}),
823 ),
824 OpRecord::new(
825 Hlc {
826 wall_ms: 7,
827 counter: 0,
828 device_id: "d1".into(),
829 },
830 1,
831 Some("op-0".into()),
832 Scope::Shared { org: "acme".into() },
833 Surface::Knowledge,
834 json!({"id": "f1"}),
835 ),
836 OpRecord::new(
837 Hlc {
838 wall_ms: 7,
839 counter: 0,
840 device_id: "d1".into(),
841 },
842 1,
843 Some("op-0".into()),
844 Scope::Personal,
845 Surface::Skill,
846 json!({"id": "f1"}),
847 ),
848 OpRecord::new(
849 Hlc {
850 wall_ms: 7,
851 counter: 0,
852 device_id: "d1".into(),
853 },
854 1,
855 Some("op-0".into()),
856 Scope::Personal,
857 Surface::Knowledge,
858 json!({"id": "f2"}),
859 ),
860 ];
861 for v in &variants {
862 assert_ne!(base.op_id, v.op_id, "changing any field changes the id");
863 }
864 }
865
866 #[test]
867 fn canonical_json_is_key_order_independent() {
868 // parse two spellings of the same object
869 let a: Value = serde_json::from_str(r#"{"b":1,"a":{"y":2,"x":3}}"#).unwrap();
870 let b: Value = serde_json::from_str(r#"{"a":{"x":3,"y":2},"b":1}"#).unwrap();
871 assert_eq!(canonical_json(&a), canonical_json(&b));
872 assert_eq!(canonical_json(&a), r#"{"a":{"x":3,"y":2},"b":1}"#);
873 }
874
875 #[test]
876 fn surface_tags_and_tiers_are_exhaustive() {
877 let surfaces = [
878 (Surface::Routing, "routing", FoldTier::GrowOnly),
879 (Surface::Declagent, "declagent", FoldTier::Registry),
880 (Surface::Conversation, "conversation", FoldTier::GrowOnly),
881 (Surface::Knowledge, "knowledge", FoldTier::GrowOnly),
882 (Surface::Skill, "skill", FoldTier::GrowOnly),
883 (
884 Surface::Registry {
885 kind: "agents".into(),
886 },
887 "registry:agents",
888 FoldTier::Registry,
889 ),
890 (Surface::Trajectory, "trajectory", FoldTier::GrowOnly),
891 (Surface::Run, "run", FoldTier::GrowOnly),
892 (Surface::Intent, "intent", FoldTier::Leased),
893 ];
894 for (s, tag, tier) in surfaces {
895 assert_eq!(s.tag(), tag);
896 assert_eq!(s.fold_tier(), tier);
897 // Intent is a logical-entity ledger, not an observation multiset.
898 assert!(!Surface::Intent.is_event_stream());
899 }
900 // Event streams (op_id-keyed multisets): routing AND conversation (B2 —
901 // op identity is turn identity). Only routing is a path-dependent
902 // REPLAY stream (retention-forbidden); conversation is an independent
903 // multiset that tolerates LastN.
904 assert!(Surface::Routing.is_event_stream() && Surface::Routing.is_replay_stream());
905 assert!(Surface::Conversation.is_event_stream());
906 assert!(!Surface::Conversation.is_replay_stream());
907 assert!(!Surface::Knowledge.is_event_stream());
908 }
909
910 #[test]
911 fn tampering_is_detected() {
912 let mut log = DeviceLog::new("d1");
913 let mut ops = vec![
914 log.append(Scope::Personal, Surface::Knowledge, json!({"id": "f1"})),
915 log.append(Scope::Personal, Surface::Knowledge, json!({"id": "f2"})),
916 ];
917 verify_log(&ops).unwrap();
918 // Mutate a payload without recomputing the id.
919 ops[1].payload = json!({"id": "f2", "body": "forged"});
920 assert!(matches!(
921 verify_log(&ops),
922 Err(ChainError::IdMismatch { .. })
923 ));
924 }
925
926 #[test]
927 fn chain_defects_are_detected() {
928 let mut log = DeviceLog::new("d1");
929 let o0 = log.append(Scope::Personal, Surface::Knowledge, json!({"id": "a"}));
930 let o1 = log.append(Scope::Personal, Surface::Knowledge, json!({"id": "b"}));
931 let o2 = log.append(Scope::Personal, Surface::Knowledge, json!({"id": "c"}));
932 verify_log(&[o0.clone(), o1.clone(), o2.clone()]).unwrap();
933
934 // Missing middle op → seq gap.
935 assert!(matches!(
936 verify_log(&[o0.clone(), o2.clone()]),
937 Err(ChainError::SeqGap {
938 expected: 1,
939 found: 2,
940 ..
941 })
942 ));
943
944 // prev link forged (re-id'd so IdMismatch doesn't fire first).
945 let forged = OpRecord::new(
946 o1.hlc.clone(),
947 o1.seq,
948 Some(o2.op_id.clone()), // wrong parent
949 o1.scope.clone(),
950 o1.surface.clone(),
951 o1.payload.clone(),
952 );
953 assert!(matches!(
954 verify_log(&[o0.clone(), forged, o2.clone()]),
955 Err(ChainError::PrevMismatch { .. })
956 ));
957
958 // hlc going backwards along the chain.
959 let backwards = OpRecord::new(
960 Hlc {
961 wall_ms: 0,
962 counter: 0,
963 device_id: "d1".into(),
964 },
965 o1.seq,
966 Some(o0.op_id.clone()),
967 o1.scope.clone(),
968 o1.surface.clone(),
969 o1.payload.clone(),
970 );
971 assert!(matches!(
972 verify_log(&[o0.clone(), backwards]),
973 Err(ChainError::NonMonotonicHlc { .. })
974 ));
975
976 // seq 0 with a parent.
977 let rooted = OpRecord::new(
978 o0.hlc.clone(),
979 0,
980 Some(o2.op_id.clone()),
981 o0.scope.clone(),
982 o0.surface.clone(),
983 o0.payload.clone(),
984 );
985 assert!(matches!(
986 verify_log(&[rooted]),
987 Err(ChainError::PrevMismatch { .. })
988 ));
989
990 // duplicate seq.
991 let dup = OpRecord::new(
992 Hlc {
993 wall_ms: 99,
994 counter: 0,
995 device_id: "d1".into(),
996 },
997 o1.seq,
998 Some(o0.op_id.clone()),
999 o1.scope.clone(),
1000 o1.surface.clone(),
1001 json!({"id": "dup"}),
1002 );
1003 assert!(matches!(
1004 verify_log(&[o0.clone(), o1.clone(), dup]),
1005 Err(ChainError::DuplicateSeq { seq: 1, .. })
1006 ));
1007
1008 // hlc.device_id disagreeing with device_id.
1009 let mut cross = o0.clone();
1010 cross.hlc.device_id = "d2".into();
1011 cross.op_id = cross.compute_op_id();
1012 assert!(matches!(
1013 verify_log(&[cross]),
1014 Err(ChainError::DeviceMismatch { .. })
1015 ));
1016 }
1017
1018 #[test]
1019 fn observe_advances_clock_past_received_ops() {
1020 let mut a = DeviceLog::new("a");
1021 let mut b = DeviceLog::new("b");
1022 let oa = a.append(Scope::Personal, Surface::Knowledge, json!({"id": "x"}));
1023 // Degenerate (logical_clock) mode: pure Lamport in the counter,
1024 // wall component pinned at 0 — B1's order semantics, same wire shape.
1025 assert_eq!(
1026 oa.hlc,
1027 Hlc {
1028 wall_ms: 0,
1029 counter: 1,
1030 device_id: "a".into()
1031 }
1032 );
1033 b.observe(&oa.hlc);
1034 let ob = b.append(Scope::Personal, Surface::Knowledge, json!({"id": "y"}));
1035 assert!(
1036 ob.hlc > oa.hlc,
1037 "causally-later write stamps above the observed op"
1038 );
1039 }
1040
1041 /// A test wall clock the test advances (or regresses) by hand.
1042 fn manual_clock() -> (std::sync::Arc<std::sync::atomic::AtomicU64>, WallClock) {
1043 let t = std::sync::Arc::new(std::sync::atomic::AtomicU64::new(0));
1044 let reader = t.clone();
1045 let wall: WallClock = Arc::new(move || reader.load(std::sync::atomic::Ordering::SeqCst));
1046 (t, wall)
1047 }
1048
1049 #[test]
1050 fn hlc_is_monotone_under_wall_clock_regression() {
1051 use std::sync::atomic::Ordering;
1052 let (t, wall) = manual_clock();
1053 let mut dev = DeviceLog::with_wall_clock("d1", wall);
1054 t.store(100, Ordering::SeqCst);
1055 let o1 = dev.append(Scope::Personal, Surface::Knowledge, json!({"id": "a"}));
1056 assert_eq!((o1.hlc.wall_ms, o1.hlc.counter), (100, 0));
1057
1058 // The wall clock jumps BACKWARDS (NTP step, VM restore): stamps keep
1059 // strictly increasing on the counter, wall pinned at the max seen.
1060 t.store(40, Ordering::SeqCst);
1061 let o2 = dev.append(Scope::Personal, Surface::Knowledge, json!({"id": "b"}));
1062 let o3 = dev.append(Scope::Personal, Surface::Knowledge, json!({"id": "c"}));
1063 assert_eq!((o2.hlc.wall_ms, o2.hlc.counter), (100, 1));
1064 assert_eq!((o3.hlc.wall_ms, o3.hlc.counter), (100, 2));
1065 assert!(o1.hlc < o2.hlc && o2.hlc < o3.hlc);
1066
1067 // The wall recovers past the pinned max: counter resets.
1068 t.store(200, Ordering::SeqCst);
1069 let o4 = dev.append(Scope::Personal, Surface::Knowledge, json!({"id": "d"}));
1070 assert_eq!((o4.hlc.wall_ms, o4.hlc.counter), (200, 0));
1071 verify_log(&[o1, o2, o3, o4]).expect("regression-spanning chain stays HLC-monotone");
1072 }
1073
1074 #[test]
1075 fn hlc_burst_within_one_millisecond_stays_strictly_ordered() {
1076 use std::sync::atomic::Ordering;
1077 let (t, wall) = manual_clock();
1078 let mut dev = DeviceLog::with_wall_clock("d1", wall);
1079 t.store(555, Ordering::SeqCst);
1080 let ops: Vec<OpRecord> = (0..50)
1081 .map(|i| dev.append(Scope::Personal, Surface::Routing, json!({"n": i})))
1082 .collect();
1083 for (i, op) in ops.iter().enumerate() {
1084 assert_eq!(op.hlc.wall_ms, 555);
1085 assert_eq!(op.hlc.counter, i as u32, "burst rides the counter");
1086 }
1087 verify_log(&ops).unwrap();
1088 }
1089
1090 #[test]
1091 fn hlc_absorbs_skewed_peer_stamps_and_preserves_causality() {
1092 use std::sync::atomic::Ordering;
1093 // Device b's wall clock runs far BEHIND device a's (skew), yet a
1094 // write on b that causally follows a's op must stamp above it.
1095 let (ta, wall_a) = manual_clock();
1096 let (tb, wall_b) = manual_clock();
1097 let mut a = DeviceLog::with_wall_clock("a", wall_a);
1098 let mut b = DeviceLog::with_wall_clock("b", wall_b);
1099 ta.store(10_000, Ordering::SeqCst);
1100 tb.store(3, Ordering::SeqCst); // b is ~10s behind
1101
1102 let oa = a.append(Scope::Personal, Surface::Knowledge, json!({"id": "x"}));
1103 b.observe(&oa.hlc); // receive rule: absorb the future stamp
1104 let ob = b.append(Scope::Personal, Surface::Knowledge, json!({"id": "y"}));
1105 assert!(ob.hlc > oa.hlc, "causality survives a 10s skew");
1106 assert_eq!(
1107 ob.hlc.wall_ms, 10_000,
1108 "wall pinned at the max witnessed, not b's slow clock"
1109 );
1110 assert_eq!(ob.hlc.counter, 1);
1111
1112 // …and once b's wall genuinely passes the witnessed max, the wall
1113 // component takes over again (the 'hybrid' half).
1114 tb.store(20_000, Ordering::SeqCst);
1115 let ob2 = b.append(Scope::Personal, Surface::Knowledge, json!({"id": "z"}));
1116 assert_eq!((ob2.hlc.wall_ms, ob2.hlc.counter), (20_000, 0));
1117 verify_log(&[ob, ob2]).unwrap();
1118 }
1119
1120 #[test]
1121 fn hlc_wire_shape_is_unchanged_from_b1() {
1122 // The B1→B3 promise: swapping the stamp source changes no wire bytes.
1123 let hlc = Hlc {
1124 wall_ms: 7,
1125 counter: 2,
1126 device_id: "d1".into(),
1127 };
1128 assert_eq!(
1129 serde_json::to_value(&hlc).unwrap(),
1130 json!({"wall_ms": 7, "counter": 2, "device_id": "d1"})
1131 );
1132 }
1133
1134 #[test]
1135 fn resume_adopts_witnessed_stamps_under_a_real_clock() {
1136 use std::sync::atomic::Ordering;
1137 let (t, wall) = manual_clock();
1138 t.store(500, Ordering::SeqCst);
1139 let mut dev = DeviceLog::with_wall_clock("d1", wall.clone());
1140 let ops = vec![
1141 dev.append(Scope::Personal, Surface::Knowledge, json!({"id": "a"})),
1142 dev.append(Scope::Personal, Surface::Knowledge, json!({"id": "b"})),
1143 ];
1144 // Restart: resume from the journal, re-attach the (now regressed)
1145 // wall — the next stamp still lands above everything persisted.
1146 t.store(100, Ordering::SeqCst);
1147 let mut resumed = DeviceLog::resume("d1", &ops).unwrap();
1148 resumed.set_wall_clock(wall);
1149 let next = resumed.append(Scope::Personal, Surface::Knowledge, json!({"id": "c"}));
1150 assert!(next.hlc > ops[1].hlc);
1151 let mut all = ops;
1152 all.push(next);
1153 verify_log(&all).unwrap();
1154 }
1155
1156 #[test]
1157 fn resume_continues_the_chain() {
1158 let mut log = DeviceLog::new("d1");
1159 let mut peer = DeviceLog::new("d2");
1160 let ops = vec![
1161 log.append(Scope::Personal, Surface::Knowledge, json!({"id": "a"})),
1162 log.append(Scope::Personal, Surface::Knowledge, json!({"id": "b"})),
1163 peer.append(Scope::Personal, Surface::Knowledge, json!({"id": "c"})),
1164 ];
1165 let mut resumed = DeviceLog::resume("d1", &ops).unwrap();
1166 let next = resumed.append(Scope::Personal, Surface::Knowledge, json!({"id": "d"}));
1167 assert_eq!(next.seq, 2);
1168 assert_eq!(next.prev.as_deref(), Some(ops[1].op_id.as_str()));
1169 let mut all = ops;
1170 all.push(next);
1171 verify_log(&all).unwrap();
1172 }
1173
1174 #[test]
1175 fn stable_key_uses_payload_id_else_content_hash() {
1176 let mut log = DeviceLog::new("d1");
1177 let with_id = log.append(
1178 Scope::Personal,
1179 Surface::Knowledge,
1180 json!({"id": "f1", "v": 1}),
1181 );
1182 assert_eq!(with_id.stable_key(), "id:f1");
1183 // `stable_key` is the logical-ENTITY key (knowledge/skills/registries):
1184 // an id-less payload keys on its canonical content hash, key-order
1185 // independent, so the same fact emitted by two devices dedups.
1186 // (Conversation is an event stream — B2 — so it does NOT use stable_key
1187 // in the fold; it keys on op_id. See fold_key / is_event_stream.)
1188 let anon1 = log.append(
1189 Scope::Personal,
1190 Surface::Knowledge,
1191 json!({"kind": "note", "body": "hi"}),
1192 );
1193 let anon2 = OpRecord::new(
1194 Hlc {
1195 wall_ms: 42,
1196 counter: 0,
1197 device_id: "d2".into(),
1198 },
1199 0,
1200 None,
1201 Scope::Personal,
1202 Surface::Knowledge,
1203 json!({"body": "hi", "kind": "note"}),
1204 );
1205 assert_eq!(anon1.stable_key(), anon2.stable_key());
1206 assert!(anon1.stable_key().starts_with("h:"));
1207 }
1208
1209 #[test]
1210 fn op_record_serde_round_trips() {
1211 let mut log = DeviceLog::new("d1");
1212 let op = log.append(
1213 Scope::Shared { org: "acme".into() },
1214 Surface::Registry {
1215 kind: "agents".into(),
1216 },
1217 json!({"id": "agent-1"}),
1218 );
1219 let json = serde_json::to_string(&op).unwrap();
1220 let back: OpRecord = serde_json::from_str(&json).unwrap();
1221 assert_eq!(back, op);
1222 assert!(back.id_valid());
1223 }
1224}