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