use serde::{Deserialize, Serialize};
use serde_json::Value;
use sha2::{Digest, Sha256};
use std::collections::BTreeMap;
use std::fmt;
use std::sync::Arc;
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
pub struct Hlc {
pub wall_ms: u64,
pub counter: u32,
pub device_id: String,
}
pub type WallClock = Arc<dyn Fn() -> u64 + Send + Sync>;
pub fn system_clock() -> WallClock {
Arc::new(|| {
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_millis() as u64)
.unwrap_or(0)
})
}
pub fn logical_clock() -> WallClock {
Arc::new(|| 0)
}
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct HlcClock {
l: u64,
c: u32,
}
impl HlcClock {
pub fn new() -> Self {
Self::default()
}
pub fn tick(&mut self, wall_now: u64, device_id: &str) -> Hlc {
if wall_now > self.l {
self.l = wall_now;
self.c = 0;
} else {
self.c = self
.c
.checked_add(1)
.expect("HLC counter overflow: > u32::MAX events without wall-clock progress");
}
Hlc {
wall_ms: self.l,
counter: self.c,
device_id: device_id.to_string(),
}
}
pub fn observe(&mut self, remote: &Hlc) {
if remote.wall_ms > self.l {
self.l = remote.wall_ms;
self.c = remote.counter;
} else if remote.wall_ms == self.l && remote.counter > self.c {
self.c = remote.counter;
}
}
pub fn witnessed(&self) -> (u64, u32) {
(self.l, self.c)
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum Scope {
Personal,
Shared { org: String },
}
impl Scope {
pub fn tag(&self) -> String {
match self {
Scope::Personal => "personal".to_string(),
Scope::Shared { org } => format!("shared:{org}"),
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum Surface {
Routing,
Declagent,
Conversation,
Knowledge,
Skill,
Registry {
kind: String,
},
Trajectory,
Run,
Intent,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum FoldTier {
GrowOnly,
Registry,
Leased,
}
impl Surface {
pub fn tag(&self) -> String {
match self {
Surface::Routing => "routing".to_string(),
Surface::Declagent => "declagent".to_string(),
Surface::Conversation => "conversation".to_string(),
Surface::Knowledge => "knowledge".to_string(),
Surface::Skill => "skill".to_string(),
Surface::Registry { kind } => format!("registry:{kind}"),
Surface::Trajectory => "trajectory".to_string(),
Surface::Run => "run".to_string(),
Surface::Intent => "intent".to_string(),
}
}
pub fn fold_tier(&self) -> FoldTier {
match self {
Surface::Conversation
| Surface::Knowledge
| Surface::Skill
| Surface::Trajectory
| Surface::Run
| Surface::Routing => FoldTier::GrowOnly,
Surface::Declagent | Surface::Registry { .. } => FoldTier::Registry,
Surface::Intent => FoldTier::Leased,
}
}
pub fn is_event_stream(&self) -> bool {
match self {
Surface::Routing | Surface::Conversation => true,
Surface::Declagent
| Surface::Knowledge
| Surface::Skill
| Surface::Registry { .. }
| Surface::Trajectory
| Surface::Run
| Surface::Intent => false,
}
}
pub fn is_replay_stream(&self) -> bool {
match self {
Surface::Routing => true,
Surface::Declagent
| Surface::Conversation
| Surface::Knowledge
| Surface::Skill
| Surface::Registry { .. }
| Surface::Trajectory
| Surface::Run
| Surface::Intent => false,
}
}
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct OpRecord {
pub op_id: String,
pub hlc: Hlc,
pub device_id: String,
pub seq: u64,
pub prev: Option<String>,
pub scope: Scope,
pub surface: Surface,
pub payload: Value,
}
pub fn canonical_json(v: &Value) -> String {
match v {
Value::Object(map) => {
let mut keys: Vec<&String> = map.keys().collect();
keys.sort();
let inner: Vec<String> = keys
.iter()
.map(|k| {
format!(
"{}:{}",
serde_json::to_string(k).expect("string serializes"),
canonical_json(&map[k.as_str()])
)
})
.collect();
format!("{{{}}}", inner.join(","))
}
Value::Array(items) => {
let inner: Vec<String> = items.iter().map(canonical_json).collect();
format!("[{}]", inner.join(","))
}
_ => serde_json::to_string(v).unwrap_or_default(),
}
}
fn sha256_hex_32(fields: &[&str]) -> String {
let mut hasher = Sha256::new();
for (i, f) in fields.iter().enumerate() {
if i > 0 {
hasher.update(b"\x1f"); }
hasher.update(f.as_bytes());
}
let digest = hasher.finalize();
digest.iter().take(16).map(|b| format!("{b:02x}")).collect()
}
impl OpRecord {
pub fn new(
hlc: Hlc,
seq: u64,
prev: Option<String>,
scope: Scope,
surface: Surface,
payload: Value,
) -> Self {
let device_id = hlc.device_id.clone();
let mut op = OpRecord {
op_id: String::new(),
hlc,
device_id,
seq,
prev,
scope,
surface,
payload,
};
op.op_id = op.compute_op_id();
op
}
pub fn compute_op_id(&self) -> String {
let hex = sha256_hex_32(&[
&self.device_id,
&self.seq.to_string(),
self.prev.as_deref().unwrap_or(""),
&self.hlc.wall_ms.to_string(),
&self.hlc.counter.to_string(),
&self.hlc.device_id,
&self.scope.tag(),
&self.surface.tag(),
&canonical_json(&self.payload),
]);
format!("op-{hex}")
}
pub fn id_valid(&self) -> bool {
self.op_id == self.compute_op_id()
}
pub fn stable_key(&self) -> String {
match self.payload.get("id").and_then(Value::as_str) {
Some(id) => format!("id:{id}"),
None => format!("h:{}", sha256_hex_32(&[&canonical_json(&self.payload)])),
}
}
pub fn fold_key(&self) -> String {
if self.surface.is_event_stream() {
format!("op:{}", self.op_id)
} else {
self.stable_key()
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum ChainError {
IdMismatch { op_id: String },
DeviceMismatch { op_id: String },
DuplicateSeq { device_id: String, seq: u64 },
SeqGap {
device_id: String,
expected: u64,
found: u64,
},
PrevMismatch { op_id: String },
NonMonotonicHlc { op_id: String },
TruncatedChain { device_id: String, first_seq: u64 },
}
impl fmt::Display for ChainError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
ChainError::IdMismatch { op_id } => {
write!(f, "op {op_id}: stored op_id does not match content")
}
ChainError::DeviceMismatch { op_id } => {
write!(f, "op {op_id}: hlc.device_id != device_id")
}
ChainError::DuplicateSeq { device_id, seq } => {
write!(f, "device {device_id}: duplicate seq {seq}")
}
ChainError::SeqGap {
device_id,
expected,
found,
} => {
write!(
f,
"device {device_id}: seq gap (expected {expected}, found {found})"
)
}
ChainError::PrevMismatch { op_id } => {
write!(f, "op {op_id}: prev does not link to the preceding op")
}
ChainError::NonMonotonicHlc { op_id } => {
write!(
f,
"op {op_id}: hlc not strictly increasing along device chain"
)
}
ChainError::TruncatedChain {
device_id,
first_seq,
} => write!(
f,
"device {device_id}: own chain starts at seq {first_seq} (truncated tail) — \
DeviceLog::resume would fork the chain; resume via checkpoint::resume_anchored"
),
}
}
}
impl std::error::Error for ChainError {}
pub fn verify_log(ops: &[OpRecord]) -> Result<(), ChainError> {
let mut by_device: BTreeMap<&str, BTreeMap<u64, &OpRecord>> = BTreeMap::new();
for op in ops {
if !op.id_valid() {
return Err(ChainError::IdMismatch {
op_id: op.op_id.clone(),
});
}
if op.hlc.device_id != op.device_id {
return Err(ChainError::DeviceMismatch {
op_id: op.op_id.clone(),
});
}
if by_device
.entry(&op.device_id)
.or_default()
.insert(op.seq, op)
.is_some()
{
return Err(ChainError::DuplicateSeq {
device_id: op.device_id.clone(),
seq: op.seq,
});
}
}
for (device_id, chain) in by_device {
let mut prev_op: Option<&OpRecord> = None;
for (&seq, op) in &chain {
match prev_op {
None => {
if seq == 0 && op.prev.is_some() {
return Err(ChainError::PrevMismatch {
op_id: op.op_id.clone(),
});
}
}
Some(previous) => {
if seq != previous.seq + 1 {
return Err(ChainError::SeqGap {
device_id: device_id.to_string(),
expected: previous.seq + 1,
found: seq,
});
}
if op.prev.as_deref() != Some(previous.op_id.as_str()) {
return Err(ChainError::PrevMismatch {
op_id: op.op_id.clone(),
});
}
if op.hlc <= previous.hlc {
return Err(ChainError::NonMonotonicHlc {
op_id: op.op_id.clone(),
});
}
}
}
prev_op = Some(op);
}
}
Ok(())
}
#[derive(Clone)]
pub struct DeviceLog {
pub(crate) device_id: String,
pub(crate) next_seq: u64,
pub(crate) prev: Option<String>,
pub(crate) clock: HlcClock,
wall: WallClock,
}
impl fmt::Debug for DeviceLog {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_struct("DeviceLog")
.field("device_id", &self.device_id)
.field("next_seq", &self.next_seq)
.field("prev", &self.prev)
.field("clock", &self.clock)
.finish_non_exhaustive() }
}
impl DeviceLog {
pub fn new(device_id: impl Into<String>) -> Self {
Self::with_wall_clock(device_id, logical_clock())
}
pub fn with_wall_clock(device_id: impl Into<String>, wall: WallClock) -> Self {
Self {
device_id: device_id.into(),
next_seq: 0,
prev: None,
clock: HlcClock::new(),
wall,
}
}
pub fn set_wall_clock(&mut self, wall: WallClock) {
self.wall = wall;
}
pub fn resume(device_id: impl Into<String>, ops: &[OpRecord]) -> Result<Self, ChainError> {
verify_log(ops)?;
let device_id = device_id.into();
if let Some(first_seq) = ops
.iter()
.filter(|op| op.device_id == device_id)
.map(|op| op.seq)
.min()
{
if first_seq > 0 {
return Err(ChainError::TruncatedChain {
device_id,
first_seq,
});
}
}
let mut log = Self::new(device_id.clone());
for op in ops {
log.clock.observe(&op.hlc);
if op.device_id == device_id && op.seq >= log.next_seq {
log.next_seq = op.seq + 1;
log.prev = Some(op.op_id.clone());
}
}
Ok(log)
}
pub fn observe(&mut self, hlc: &Hlc) {
self.clock.observe(hlc);
}
pub fn append(&mut self, scope: Scope, surface: Surface, payload: Value) -> OpRecord {
let hlc = self.clock.tick((self.wall)(), &self.device_id);
let op = OpRecord::new(
hlc,
self.next_seq,
self.prev.take(),
scope,
surface,
payload,
);
self.next_seq += 1;
self.prev = Some(op.op_id.clone());
op
}
pub fn device_id(&self) -> &str {
&self.device_id
}
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
#[test]
fn op_id_is_deterministic_and_content_derived() {
let mk = || {
OpRecord::new(
Hlc {
wall_ms: 7,
counter: 0,
device_id: "d1".into(),
},
0,
None,
Scope::Personal,
Surface::Knowledge,
json!({"id": "f1", "body": "x"}),
)
};
let a = mk();
let b = mk();
assert_eq!(a.op_id, b.op_id, "identical content → identical id");
assert!(a.op_id.starts_with("op-"));
assert_eq!(a.op_id.len(), 3 + 32);
assert!(a.id_valid());
}
#[test]
fn op_id_covers_every_field() {
let base = OpRecord::new(
Hlc {
wall_ms: 7,
counter: 0,
device_id: "d1".into(),
},
1,
Some("op-0".into()),
Scope::Personal,
Surface::Knowledge,
json!({"id": "f1"}),
);
let variants = [
OpRecord::new(
Hlc {
wall_ms: 8,
counter: 0,
device_id: "d1".into(),
},
1,
Some("op-0".into()),
Scope::Personal,
Surface::Knowledge,
json!({"id": "f1"}),
),
OpRecord::new(
Hlc {
wall_ms: 7,
counter: 0,
device_id: "d1".into(),
},
2,
Some("op-0".into()),
Scope::Personal,
Surface::Knowledge,
json!({"id": "f1"}),
),
OpRecord::new(
Hlc {
wall_ms: 7,
counter: 0,
device_id: "d1".into(),
},
1,
Some("op-1".into()),
Scope::Personal,
Surface::Knowledge,
json!({"id": "f1"}),
),
OpRecord::new(
Hlc {
wall_ms: 7,
counter: 0,
device_id: "d1".into(),
},
1,
Some("op-0".into()),
Scope::Shared { org: "acme".into() },
Surface::Knowledge,
json!({"id": "f1"}),
),
OpRecord::new(
Hlc {
wall_ms: 7,
counter: 0,
device_id: "d1".into(),
},
1,
Some("op-0".into()),
Scope::Personal,
Surface::Skill,
json!({"id": "f1"}),
),
OpRecord::new(
Hlc {
wall_ms: 7,
counter: 0,
device_id: "d1".into(),
},
1,
Some("op-0".into()),
Scope::Personal,
Surface::Knowledge,
json!({"id": "f2"}),
),
];
for v in &variants {
assert_ne!(base.op_id, v.op_id, "changing any field changes the id");
}
}
#[test]
fn canonical_json_is_key_order_independent() {
let a: Value = serde_json::from_str(r#"{"b":1,"a":{"y":2,"x":3}}"#).unwrap();
let b: Value = serde_json::from_str(r#"{"a":{"x":3,"y":2},"b":1}"#).unwrap();
assert_eq!(canonical_json(&a), canonical_json(&b));
assert_eq!(canonical_json(&a), r#"{"a":{"x":3,"y":2},"b":1}"#);
}
#[test]
fn surface_tags_and_tiers_are_exhaustive() {
let surfaces = [
(Surface::Routing, "routing", FoldTier::GrowOnly),
(Surface::Declagent, "declagent", FoldTier::Registry),
(Surface::Conversation, "conversation", FoldTier::GrowOnly),
(Surface::Knowledge, "knowledge", FoldTier::GrowOnly),
(Surface::Skill, "skill", FoldTier::GrowOnly),
(
Surface::Registry {
kind: "agents".into(),
},
"registry:agents",
FoldTier::Registry,
),
(Surface::Trajectory, "trajectory", FoldTier::GrowOnly),
(Surface::Run, "run", FoldTier::GrowOnly),
(Surface::Intent, "intent", FoldTier::Leased),
];
for (s, tag, tier) in surfaces {
assert_eq!(s.tag(), tag);
assert_eq!(s.fold_tier(), tier);
assert!(!Surface::Intent.is_event_stream());
}
assert!(Surface::Routing.is_event_stream() && Surface::Routing.is_replay_stream());
assert!(Surface::Conversation.is_event_stream());
assert!(!Surface::Conversation.is_replay_stream());
assert!(!Surface::Knowledge.is_event_stream());
}
#[test]
fn tampering_is_detected() {
let mut log = DeviceLog::new("d1");
let mut ops = vec![
log.append(Scope::Personal, Surface::Knowledge, json!({"id": "f1"})),
log.append(Scope::Personal, Surface::Knowledge, json!({"id": "f2"})),
];
verify_log(&ops).unwrap();
ops[1].payload = json!({"id": "f2", "body": "forged"});
assert!(matches!(
verify_log(&ops),
Err(ChainError::IdMismatch { .. })
));
}
#[test]
fn chain_defects_are_detected() {
let mut log = DeviceLog::new("d1");
let o0 = log.append(Scope::Personal, Surface::Knowledge, json!({"id": "a"}));
let o1 = log.append(Scope::Personal, Surface::Knowledge, json!({"id": "b"}));
let o2 = log.append(Scope::Personal, Surface::Knowledge, json!({"id": "c"}));
verify_log(&[o0.clone(), o1.clone(), o2.clone()]).unwrap();
assert!(matches!(
verify_log(&[o0.clone(), o2.clone()]),
Err(ChainError::SeqGap {
expected: 1,
found: 2,
..
})
));
let forged = OpRecord::new(
o1.hlc.clone(),
o1.seq,
Some(o2.op_id.clone()), o1.scope.clone(),
o1.surface.clone(),
o1.payload.clone(),
);
assert!(matches!(
verify_log(&[o0.clone(), forged, o2.clone()]),
Err(ChainError::PrevMismatch { .. })
));
let backwards = OpRecord::new(
Hlc {
wall_ms: 0,
counter: 0,
device_id: "d1".into(),
},
o1.seq,
Some(o0.op_id.clone()),
o1.scope.clone(),
o1.surface.clone(),
o1.payload.clone(),
);
assert!(matches!(
verify_log(&[o0.clone(), backwards]),
Err(ChainError::NonMonotonicHlc { .. })
));
let rooted = OpRecord::new(
o0.hlc.clone(),
0,
Some(o2.op_id.clone()),
o0.scope.clone(),
o0.surface.clone(),
o0.payload.clone(),
);
assert!(matches!(
verify_log(&[rooted]),
Err(ChainError::PrevMismatch { .. })
));
let dup = OpRecord::new(
Hlc {
wall_ms: 99,
counter: 0,
device_id: "d1".into(),
},
o1.seq,
Some(o0.op_id.clone()),
o1.scope.clone(),
o1.surface.clone(),
json!({"id": "dup"}),
);
assert!(matches!(
verify_log(&[o0.clone(), o1.clone(), dup]),
Err(ChainError::DuplicateSeq { seq: 1, .. })
));
let mut cross = o0.clone();
cross.hlc.device_id = "d2".into();
cross.op_id = cross.compute_op_id();
assert!(matches!(
verify_log(&[cross]),
Err(ChainError::DeviceMismatch { .. })
));
}
#[test]
fn observe_advances_clock_past_received_ops() {
let mut a = DeviceLog::new("a");
let mut b = DeviceLog::new("b");
let oa = a.append(Scope::Personal, Surface::Knowledge, json!({"id": "x"}));
assert_eq!(
oa.hlc,
Hlc {
wall_ms: 0,
counter: 1,
device_id: "a".into()
}
);
b.observe(&oa.hlc);
let ob = b.append(Scope::Personal, Surface::Knowledge, json!({"id": "y"}));
assert!(
ob.hlc > oa.hlc,
"causally-later write stamps above the observed op"
);
}
fn manual_clock() -> (std::sync::Arc<std::sync::atomic::AtomicU64>, WallClock) {
let t = std::sync::Arc::new(std::sync::atomic::AtomicU64::new(0));
let reader = t.clone();
let wall: WallClock = Arc::new(move || reader.load(std::sync::atomic::Ordering::SeqCst));
(t, wall)
}
#[test]
fn hlc_is_monotone_under_wall_clock_regression() {
use std::sync::atomic::Ordering;
let (t, wall) = manual_clock();
let mut dev = DeviceLog::with_wall_clock("d1", wall);
t.store(100, Ordering::SeqCst);
let o1 = dev.append(Scope::Personal, Surface::Knowledge, json!({"id": "a"}));
assert_eq!((o1.hlc.wall_ms, o1.hlc.counter), (100, 0));
t.store(40, Ordering::SeqCst);
let o2 = dev.append(Scope::Personal, Surface::Knowledge, json!({"id": "b"}));
let o3 = dev.append(Scope::Personal, Surface::Knowledge, json!({"id": "c"}));
assert_eq!((o2.hlc.wall_ms, o2.hlc.counter), (100, 1));
assert_eq!((o3.hlc.wall_ms, o3.hlc.counter), (100, 2));
assert!(o1.hlc < o2.hlc && o2.hlc < o3.hlc);
t.store(200, Ordering::SeqCst);
let o4 = dev.append(Scope::Personal, Surface::Knowledge, json!({"id": "d"}));
assert_eq!((o4.hlc.wall_ms, o4.hlc.counter), (200, 0));
verify_log(&[o1, o2, o3, o4]).expect("regression-spanning chain stays HLC-monotone");
}
#[test]
fn hlc_burst_within_one_millisecond_stays_strictly_ordered() {
use std::sync::atomic::Ordering;
let (t, wall) = manual_clock();
let mut dev = DeviceLog::with_wall_clock("d1", wall);
t.store(555, Ordering::SeqCst);
let ops: Vec<OpRecord> = (0..50)
.map(|i| dev.append(Scope::Personal, Surface::Routing, json!({"n": i})))
.collect();
for (i, op) in ops.iter().enumerate() {
assert_eq!(op.hlc.wall_ms, 555);
assert_eq!(op.hlc.counter, i as u32, "burst rides the counter");
}
verify_log(&ops).unwrap();
}
#[test]
fn hlc_absorbs_skewed_peer_stamps_and_preserves_causality() {
use std::sync::atomic::Ordering;
let (ta, wall_a) = manual_clock();
let (tb, wall_b) = manual_clock();
let mut a = DeviceLog::with_wall_clock("a", wall_a);
let mut b = DeviceLog::with_wall_clock("b", wall_b);
ta.store(10_000, Ordering::SeqCst);
tb.store(3, Ordering::SeqCst);
let oa = a.append(Scope::Personal, Surface::Knowledge, json!({"id": "x"}));
b.observe(&oa.hlc); let ob = b.append(Scope::Personal, Surface::Knowledge, json!({"id": "y"}));
assert!(ob.hlc > oa.hlc, "causality survives a 10s skew");
assert_eq!(
ob.hlc.wall_ms, 10_000,
"wall pinned at the max witnessed, not b's slow clock"
);
assert_eq!(ob.hlc.counter, 1);
tb.store(20_000, Ordering::SeqCst);
let ob2 = b.append(Scope::Personal, Surface::Knowledge, json!({"id": "z"}));
assert_eq!((ob2.hlc.wall_ms, ob2.hlc.counter), (20_000, 0));
verify_log(&[ob, ob2]).unwrap();
}
#[test]
fn hlc_wire_shape_is_unchanged_from_b1() {
let hlc = Hlc {
wall_ms: 7,
counter: 2,
device_id: "d1".into(),
};
assert_eq!(
serde_json::to_value(&hlc).unwrap(),
json!({"wall_ms": 7, "counter": 2, "device_id": "d1"})
);
}
#[test]
fn resume_adopts_witnessed_stamps_under_a_real_clock() {
use std::sync::atomic::Ordering;
let (t, wall) = manual_clock();
t.store(500, Ordering::SeqCst);
let mut dev = DeviceLog::with_wall_clock("d1", wall.clone());
let ops = vec![
dev.append(Scope::Personal, Surface::Knowledge, json!({"id": "a"})),
dev.append(Scope::Personal, Surface::Knowledge, json!({"id": "b"})),
];
t.store(100, Ordering::SeqCst);
let mut resumed = DeviceLog::resume("d1", &ops).unwrap();
resumed.set_wall_clock(wall);
let next = resumed.append(Scope::Personal, Surface::Knowledge, json!({"id": "c"}));
assert!(next.hlc > ops[1].hlc);
let mut all = ops;
all.push(next);
verify_log(&all).unwrap();
}
#[test]
fn resume_continues_the_chain() {
let mut log = DeviceLog::new("d1");
let mut peer = DeviceLog::new("d2");
let ops = vec![
log.append(Scope::Personal, Surface::Knowledge, json!({"id": "a"})),
log.append(Scope::Personal, Surface::Knowledge, json!({"id": "b"})),
peer.append(Scope::Personal, Surface::Knowledge, json!({"id": "c"})),
];
let mut resumed = DeviceLog::resume("d1", &ops).unwrap();
let next = resumed.append(Scope::Personal, Surface::Knowledge, json!({"id": "d"}));
assert_eq!(next.seq, 2);
assert_eq!(next.prev.as_deref(), Some(ops[1].op_id.as_str()));
let mut all = ops;
all.push(next);
verify_log(&all).unwrap();
}
#[test]
fn stable_key_uses_payload_id_else_content_hash() {
let mut log = DeviceLog::new("d1");
let with_id = log.append(
Scope::Personal,
Surface::Knowledge,
json!({"id": "f1", "v": 1}),
);
assert_eq!(with_id.stable_key(), "id:f1");
let anon1 = log.append(
Scope::Personal,
Surface::Knowledge,
json!({"kind": "note", "body": "hi"}),
);
let anon2 = OpRecord::new(
Hlc {
wall_ms: 42,
counter: 0,
device_id: "d2".into(),
},
0,
None,
Scope::Personal,
Surface::Knowledge,
json!({"body": "hi", "kind": "note"}),
);
assert_eq!(anon1.stable_key(), anon2.stable_key());
assert!(anon1.stable_key().starts_with("h:"));
}
#[test]
fn op_record_serde_round_trips() {
let mut log = DeviceLog::new("d1");
let op = log.append(
Scope::Shared { org: "acme".into() },
Surface::Registry {
kind: "agents".into(),
},
json!({"id": "agent-1"}),
);
let json = serde_json::to_string(&op).unwrap();
let back: OpRecord = serde_json::from_str(&json).unwrap();
assert_eq!(back, op);
assert!(back.id_valid());
}
}