Skip to main content

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}