Skip to main content

max / makenotwork

synckit: bind entry ciphertext to (table,row_id), explicit wire version, HLC overflow guard (ultra-fuzz Run #1 Sync) - push/pull now seal a versioned HLC envelope (__skver:2) and AEAD-bind each entry to aad_for_entry(table,row_id); decrypt auto-detects the sk2: wire tag. Closes X1a (entry relocation fails closed) and X2 (envelope detection keys on the embedded HLC, not a structural 'data'-key guess). - rotation: re-encrypt binds the same AAD; server echoes table_name/row_id in rotation entries (get_rotation_entries + RotationEntry) so re-encryption can recompute it. Legacy entries upgrade to v2 opportunistically. - HLC tick/observe saturate the counter and roll into wall_ms on overflow, so a hostile peer's counter:u32::MAX can't wrap the clock backwards. - conflict: exact-HLC ties break on canonical payload bytes (converges even on a shared-node cloned install); document the clean-path HLC-gate and apply-then-persist-cursor caller contracts. - architecture.md: document the HLC envelope, AAD binding, wire tag, Deletes. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Author: Max Johnson <me@maxj.phd> · 2026-06-19 22:21 UTC
Commit: e4266f1da83152f4f6f0bbe61b515abef2a2c64a
Parent: 225a0ee
10 files changed, +227 insertions, -44 deletions
@@ -113,10 +113,10 @@ pub async fn get_rotation_entries(
113 113 new_key_id: i32,
114 114 after_seq: i64,
115 115 limit: i64,
116 - ) -> Result<Vec<(i64, Option<JsonValue>)>> {
117 - let entries: Vec<(i64, Option<JsonValue>)> = sqlx::query_as(
116 + ) -> Result<Vec<(i64, String, String, Option<JsonValue>)>> {
117 + let entries: Vec<(i64, String, String, Option<JsonValue>)> = sqlx::query_as(
118 118 r#"
119 - SELECT seq, data FROM sync_log
119 + SELECT seq, table_name, row_id, data FROM sync_log
120 120 WHERE app_id = $1 AND user_id = $2
121 121 AND seq > $3
122 122 AND (key_id IS NULL OR key_id != $5)
@@ -320,6 +320,10 @@ pub(crate) struct RotationEntriesResponse {
320 320 #[derive(Serialize, utoipa::ToSchema)]
321 321 pub(crate) struct RotationEntry {
322 322 seq: i64,
323 + /// Source table and row id, echoed so the client can recompute the entry's
324 + /// AEAD associated data when re-encrypting under the new key.
325 + table: String,
326 + row_id: String,
323 327 data: Option<serde_json::Value>,
324 328 }
325 329
@@ -714,7 +714,7 @@ pub(super) async fn rotation_entries(
714 714 let has_more = raw_entries.len() as i64 == page_size;
715 715 let entries: Vec<RotationEntry> = raw_entries
716 716 .into_iter()
717 - .map(|(seq, data)| RotationEntry { seq, data })
717 + .map(|(seq, table, row_id, data)| RotationEntry { seq, table, row_id, data })
718 718 .collect();
719 719
720 720 Ok(Json(RotationEntriesResponse { entries, has_more }))
@@ -7,7 +7,7 @@ end-to-end encrypted cloud sync against the MNW SyncKit server. Consumer apps
7 7 (GoingsOn, Balanced Breakfast, audiofiles) use this crate to push and pull
8 8 changelog entries without the server ever seeing plaintext data.
9 9
10 - Version: 0.3.1.
10 + Version: 0.4.0.
11 11
12 12 ## Crate structure
13 13
@@ -122,12 +122,46 @@ from the OS keychain without any server interaction or password prompt.
122 122
123 123 ## Push/Pull protocol
124 124
125 + ### The HLC envelope
126 +
127 + Every change — **including Deletes** — is encrypted as a JSON *envelope*, not as
128 + the bare row data:
129 +
130 + ```json
131 + { "__skver": 2, "__skhlc": { "wall_ms": …, "counter": …, "node": "…" }, "data": <row|null> }
132 + ```
133 +
134 + Encrypting the envelope (rather than just the row) is what carries the hybrid
135 + logical clock (HLC) inside the E2E ciphertext, so the server can order entries by
136 + `seq` but never sees or orders by the clock. A Delete has `data: null` but still
137 + seals an envelope, so its HLC travels too. On decrypt, a payload whose embedded
138 + `__skhlc` parses as an `Hlc` is treated as an envelope; anything else is a legacy
139 + bare row whose HLC is synthesized from `node` + the entry's `client_timestamp`.
140 +
141 + ### Wire version tag and AAD binding
142 +
143 + The encrypted `data` string is **position-bound**. v2 ciphertext is prefixed
144 + `sk2:` and sealed with AEAD associated data `aad_for_entry(table, row_id)` =
145 + `table` `0x1f` `row_id`. Because the tag is read before decryption, the reader
146 + always knows which AAD to apply:
147 +
148 + - `sk2:`-tagged → open with the entry's `(table, row_id)` AAD.
149 + - untagged (legacy, pre-v2) → open with empty AAD.
150 +
151 + This means a malicious or compromised server cannot relocate a valid ciphertext
152 + to a different row or table: the AAD no longer matches and the open fails closed.
153 + Legacy and v2 ciphertext coexist with no flag-day; key rotation re-encrypts
154 + legacy entries into the v2 form opportunistically.
155 +
125 156 ### Push
126 157
127 - 1. Caller provides a `Vec<ChangeEntry>` with plaintext `data` fields.
128 - 2. Client serializes each `data` field to JSON bytes, encrypts with the master
129 - key (XChaCha20-Poly1305, random nonce), and base64-encodes the result.
130 - 3. The encrypted payload is POSTed to `/api/sync/push` with the device ID.
158 + 1. Caller provides a `Vec<ChangeEntry>` with plaintext `data` fields and an HLC.
159 + 2. Client wraps each `(hlc, data)` in an envelope, encrypts it with the master
160 + key (XChaCha20-Poly1305, random nonce, `(table, row_id)` AAD), and emits the
161 + `sk2:`-tagged base64 string.
162 + 3. The encrypted payload is POSTed to `/api/sync/push` with the device ID and a
163 + client-generated `batch_id` (idempotent push: a replayed batch returns the
164 + existing cursor).
131 165 4. Server appends entries to the changelog and returns the new cursor (i64).
132 166 5. Retries on transient failures (see below).
133 167
@@ -136,10 +170,27 @@ from the OS keychain without any server interaction or password prompt.
136 170 1. Client POSTs to `/api/sync/pull` with the device ID and last-known cursor.
137 171 2. Server returns changelog entries since that cursor, a new cursor, and a
138 172 `has_more` flag for pagination.
139 - 3. Client decrypts each entry's `data` field and returns plaintext `ChangeEntry`
140 - values to the caller.
173 + 3. Client decrypts each entry's envelope (auto-detecting the wire version) and
174 + returns plaintext `ChangeEntry` values, HLC included, to the caller.
141 175 4. Retries on transient failures.
142 176
177 + **Caller contract.** The SDK does not persist the cursor or dedup re-delivered
178 + changes. Persist the returned cursor only *after* applying the returned changes
179 + (same transaction where possible), and make apply idempotent per
180 + `(table, row_id, hlc)` — a reconnect can re-deliver a batch.
181 +
182 + ### Conflict resolution
183 +
184 + Conflicts are resolved client-side (the server cannot read plaintext).
185 + `resolve_lww` orders by HLC, operation-agnostically: the higher `(wall_ms,
186 + counter, node)` wins, so a newer edit beats an older delete and vice-versa, and
187 + every device converges on the same winner. An *exactly* equal HLC normally means
188 + a same-device echo; if two installs share a `node` UUID (cloned config), the tie
189 + is broken deterministically on the canonical payload bytes so both devices still
190 + converge. `detect_conflicts` only flags rows with an *un-pushed* local edit;
191 + "clean" changes must still be HLC-gated by the caller against committed local
192 + state before applying.
193 +
143 194 ### Sequence numbers
144 195
145 196 The server assigns a monotonically increasing sequence number (`seq`) to each
@@ -232,5 +283,10 @@ storing long-lived credentials in memory.
232 283 loaded) but never log key bytes or ciphertext.
233 284 - **Minimum ciphertext size**: Decryption rejects inputs shorter than 40 bytes
234 285 (24-byte nonce + 16-byte tag), preventing trivial malformed-input attacks.
286 + - **Ciphertext position binding (AAD)**: v2 entry ciphertext is sealed with
287 + `(table, row_id)` as AEAD associated data, so the untrusted server cannot
288 + relocate a valid ciphertext to another row/table without the open failing
289 + closed. See "Wire version tag and AAD binding".
235 290 - **Envelope versioning**: The key envelope includes a version field (`v: 1`)
236 - to support future algorithm upgrades without breaking existing envelopes.
291 + and travels with its Argon2 cost parameters, so the work factor can be raised
292 + later without breaking existing envelopes.
@@ -136,20 +136,26 @@ impl SyncKitClient {
136 136 /// envelope (rather than the bare row payload) is what carries the HLC inside
137 137 /// the E2E ciphertext — including for Deletes, which have no row payload. The
138 138 /// server stores the ciphertext opaquely and never sees the clock.
139 + ///
140 + /// `__skver: 2` is a positive version marker that, together with the `sk2:`
141 + /// wire tag on the ciphertext, removes the need to structurally guess whether
142 + /// a decrypted value is an envelope or a bare legacy row.
139 143 fn hlc_envelope(hlc: &Hlc, data: &Option<serde_json::Value>) -> serde_json::Value {
140 - serde_json::json!({ "__skhlc": hlc, "data": data })
144 + serde_json::json!({ "__skver": 2, "__skhlc": hlc, "data": data })
141 145 }
142 146
143 - /// Split a decrypted payload back into `(hlc, data)`. Handles both the HLC
144 - /// envelope and legacy bare-row payloads (entries pushed before HLC support),
145 - /// for which the HLC is synthesized from `node` + `timestamp_ms`.
147 + /// Split a decrypted payload back into `(hlc, data)`. A payload carrying an
148 + /// embedded `__skhlc` that parses as an [`Hlc`] is an envelope (gen1/gen2);
149 + /// anything else is a legacy bare-row payload whose HLC is synthesized from
150 + /// `node` + `timestamp_ms`. Detection keys on the embedded HLC's presence —
151 + /// not on a generic `data` key — so an app row that merely happens to contain
152 + /// a `data` field is never misread as an envelope.
146 153 fn split_hlc_envelope(
147 154 decrypted: serde_json::Value,
148 155 node: uuid::Uuid,
149 156 timestamp_ms: i64,
150 157 ) -> (Hlc, Option<serde_json::Value>) {
151 158 if let Some(obj) = decrypted.as_object()
152 - && obj.contains_key("data")
153 159 && let Some(hlc) = obj
154 160 .get("__skhlc")
155 161 .and_then(|v| serde_json::from_value::<Hlc>(v.clone()).ok())
@@ -163,10 +169,13 @@ impl SyncKitClient {
163 169
164 170 /// Encrypt with a pre-loaded key. Used by `push()` to avoid per-entry lock
165 171 /// acquisition. Every change (including Deletes) is encrypted, because the HLC
166 - /// envelope always needs sealing.
172 + /// envelope always needs sealing. The ciphertext is bound to its
173 + /// `(table, row_id)` address via AEAD associated data, so a server cannot
174 + /// relocate it to another row/table without the open failing closed.
167 175 pub(super) fn encrypt_change_with_key(entry: ChangeEntry, master_key: &[u8; 32]) -> Result<WireChangeEntry> {
176 + let aad = crypto::aad_for_entry(&entry.table, &entry.row_id);
168 177 let envelope = Self::hlc_envelope(&entry.hlc, &entry.data);
169 - let encrypted_data = Some(crypto::encrypt_json(&envelope, master_key)?);
178 + let encrypted_data = Some(crypto::encrypt_json_aad(&envelope, master_key, &aad)?);
170 179
171 180 Ok(WireChangeEntry {
172 181 table: entry.table,
@@ -230,9 +239,10 @@ impl SyncKitClient {
230 239 /// Inner decryption that borrows the entry (for fallback logic).
231 240 #[allow(dead_code)]
232 241 fn decrypt_change_with_key_inner(entry: &PullChangeEntry, master_key: &[u8; 32]) -> Result<ChangeEntry> {
242 + let aad = crypto::aad_for_entry(&entry.table, &entry.row_id);
233 243 let (hlc, data) = match entry.data {
234 244 Some(ref value) => {
235 - let decrypted = crypto::decrypt_json(value, master_key)?;
245 + let decrypted = crypto::decrypt_json_aad(value, master_key, &aad)?;
236 246 Self::split_hlc_envelope(decrypted, entry.device_id, entry.timestamp.timestamp_millis())
237 247 }
238 248 None => (
@@ -253,9 +263,10 @@ impl SyncKitClient {
253 263
254 264 /// Decrypt with a pre-loaded key. Used by `pull()` to avoid per-entry lock acquisition.
255 265 pub(super) fn decrypt_change_with_key(entry: PullChangeEntry, master_key: &[u8; 32]) -> Result<ChangeEntry> {
266 + let aad = crypto::aad_for_entry(&entry.table, &entry.row_id);
256 267 let (hlc, data) = match entry.data {
257 268 Some(ref value) => {
258 - let decrypted = crypto::decrypt_json(value, master_key)?;
269 + let decrypted = crypto::decrypt_json_aad(value, master_key, &aad)?;
259 270 Self::split_hlc_envelope(decrypted, entry.device_id, entry.timestamp.timestamp_millis())
260 271 }
261 272 None => (
@@ -155,9 +155,13 @@ impl SyncKitClient {
155 155 .map(|entry| {
156 156 let new_data = match entry.data {
157 157 Some(ref encrypted_value) => {
158 - // Decrypt with old key, re-encrypt with new key
159 - let plaintext = crypto::decrypt_json(encrypted_value, old_key)?;
160 - let reencrypted = crypto::encrypt_json(&plaintext, new_key)?;
158 + // Decrypt with old key, re-encrypt with new key — binding the
159 + // same (table, row_id) AAD on both ends. A legacy untagged
160 + // entry decrypts with empty AAD and re-emits as a v2 tagged,
161 + // position-bound payload (opportunistic upgrade).
162 + let aad = crypto::aad_for_entry(&entry.table, &entry.row_id);
163 + let plaintext = crypto::decrypt_json_aad(encrypted_value, old_key, &aad)?;
164 + let reencrypted = crypto::encrypt_json_aad(&plaintext, new_key, &aad)?;
161 165 Some(reencrypted)
162 166 }
163 167 None => None, // DELETE entries have no data
@@ -385,11 +389,13 @@ mod tests {
385 389 assert_eq!(resp.target_seq, 100);
386 390 assert_eq!(resp.new_key_id, 2);
387 391
388 - let json = r#"{"entries": [{"seq": 1, "data": "encrypted"}, {"seq": 2, "data": null}], "has_more": true}"#;
392 + let json = r#"{"entries": [{"seq": 1, "table": "tasks", "row_id": "r1", "data": "encrypted"}, {"seq": 2, "table": "tasks", "row_id": "r2", "data": null}], "has_more": true}"#;
389 393 let resp: RotationEntriesResponse = serde_json::from_str(json).unwrap();
390 394 assert_eq!(resp.entries.len(), 2);
391 395 assert!(resp.has_more);
392 396 assert!(resp.entries[0].data.is_some());
397 + assert_eq!(resp.entries[0].table, "tasks");
398 + assert_eq!(resp.entries[0].row_id, "r1");
393 399 assert!(resp.entries[1].data.is_none());
394 400 }
395 401
@@ -87,10 +87,10 @@ fn parse_sse_block_is_changed(block: &str) -> bool {
87 87 if trimmed.starts_with(':') {
88 88 continue;
89 89 }
90 - if let Some(value) = trimmed.strip_prefix("event:") {
91 - if value.trim() == "changed" {
92 - return true;
93 - }
90 + if let Some(value) = trimmed.strip_prefix("event:")
91 + && value.trim() == "changed"
92 + {
93 + return true;
94 94 }
95 95 }
96 96 false
@@ -108,6 +108,13 @@ impl SyncKitClient {
108 108 /// Returns (changes, new_cursor, has_more).
109 109 ///
110 110 /// Retries on transient failures (network errors, 5xx, 429) with exponential backoff.
111 + ///
112 + /// **Contract — persist the cursor only after applying.** The returned cursor
113 + /// must be written to durable storage *after* the returned changes are
114 + /// applied, in the same transaction where possible. Persisting the cursor
115 + /// first and crashing before apply silently drops a batch; the SDK does not
116 + /// dedup re-delivered changes, so apply must also be idempotent per
117 + /// `(table, row_id, hlc)`.
111 118 #[instrument(skip(self))]
112 119 pub async fn pull(
113 120 &self,
@@ -69,7 +69,16 @@ pub trait ConflictResolver: Send + Sync {
69 69 /// each produces a separate `ConflictPair` with a clone of the same local
70 70 /// entry. The caller must resolve them in order (by `seq`).
71 71 ///
72 - /// Returns `(clean, conflicts)` where `clean` changes can be applied directly.
72 + /// Returns `(clean, conflicts)` where `clean` changes have no *un-pushed* local
73 + /// edit to conflict with.
74 + ///
75 + /// **Contract — clean changes still need HLC gating.** "Clean" means only that
76 + /// no local *pending* edit contests the row. It does **not** mean the remote
77 + /// change is newer than what is already committed locally: a device that has
78 + /// applied and pushed a newer edit, then pulls an older remote edit for the same
79 + /// row, will see that older edit in `clean`. The caller must compare each clean
80 + /// change's [`Hlc`](crate::types::Hlc) against the row's committed HLC and skip
81 + /// it if older. Applying clean changes blindly can clobber a newer local value.
73 82 pub fn detect_conflicts(
74 83 remote: Vec<PulledChange>,
75 84 local_pending: &[ChangeEntry],
@@ -117,14 +126,35 @@ pub fn detect_conflicts(
117 126 /// This replaces the prior wall-clock + "delete always wins" rule. Delete-wins
118 127 /// caused permanent loss when a delete on one device beat a strictly-newer edit on
119 128 /// another (ultra-fuzz Run #28); under HLC ordering a newer edit beats an older
120 - /// delete and vice-versa, by clock value alone. `>=` only ties to local for an
121 - /// identical HLC, which (distinct `node`) means a same-device echo, not a real
122 - /// cross-device conflict.
129 + /// delete and vice-versa, by clock value alone.
130 + ///
131 + /// An *exactly* equal HLC normally means a same-device echo, since a distinct
132 + /// `node` makes the value unique. It can only collide across devices if two
133 + /// installs share a `node` UUID (a cloned config dir or a restored backup). To
134 + /// stay convergent even then, an exact tie is broken on the canonical payload
135 + /// bytes — a deterministic order both devices compute identically — rather than
136 + /// unconditionally keeping local (which would let A keep `a` while B keeps `b`).
123 137 pub fn resolve_lww(local: &ChangeEntry, remote: &PulledChange) -> Resolution {
124 - if local.hlc >= remote.entry.hlc {
125 - Resolution::KeepLocal
126 - } else {
127 - Resolution::KeepRemote
138 + match local.hlc.cmp(&remote.entry.hlc) {
139 + std::cmp::Ordering::Greater => Resolution::KeepLocal,
140 + std::cmp::Ordering::Less => Resolution::KeepRemote,
141 + std::cmp::Ordering::Equal => {
142 + if canonical_payload(&local.data) >= canonical_payload(&remote.entry.data) {
143 + Resolution::KeepLocal
144 + } else {
145 + Resolution::KeepRemote
146 + }
147 + }
148 + }
149 + }
150 +
151 + /// Deterministic byte encoding of a row payload for the exact-HLC tiebreak.
152 + /// `serde_json`'s default `Map` is sorted, so equal values always serialize to
153 + /// equal bytes on every device — the property the tiebreak relies on.
154 + fn canonical_payload(data: &Option<serde_json::Value>) -> Vec<u8> {
155 + match data {
156 + Some(v) => serde_json::to_vec(v).unwrap_or_default(),
157 + None => Vec::new(),
128 158 }
129 159 }
130 160
@@ -467,6 +497,34 @@ mod tests {
467 497 }
468 498
469 499 #[test]
500 + fn lww_identical_hlc_node_collision_converges_on_payload() {
501 + // Pathological case: two installs share a node UUID (cloned config), so
502 + // two genuinely different edits produce a byte-identical HLC. The payload
503 + // tiebreak must still make both devices land on the same value.
504 + let shared = Uuid::from_u128(7);
505 + let hlc = Hlc { wall_ms: 1000, counter: 3, node: shared };
506 + let val_a = json!({"v": "aaa"});
507 + let val_b = json!({"v": "bbb"}); // canonically greater than val_a
508 +
509 + // Device A: local = a, remote = b.
510 + let mut local_a = make_entry("tasks", "r1", ChangeOp::Update, Utc::now());
511 + local_a.hlc = hlc;
512 + local_a.data = Some(val_a.clone());
513 + let mut remote_b = pulled_with_hlc("r1", ChangeOp::Update, hlc, shared);
514 + remote_b.entry.data = Some(val_b.clone());
515 + // b's payload sorts higher, so A drops local and keeps remote (b).
516 + assert!(matches!(resolve_lww(&local_a, &remote_b), Resolution::KeepRemote));
517 +
518 + // Device B: local = b, remote = a — must keep local (b). Both converge on b.
519 + let mut local_b = make_entry("tasks", "r1", ChangeOp::Update, Utc::now());
520 + local_b.hlc = hlc;
521 + local_b.data = Some(val_b);
522 + let mut remote_a = pulled_with_hlc("r1", ChangeOp::Update, hlc, shared);
523 + remote_a.entry.data = Some(val_a);
524 + assert!(matches!(resolve_lww(&local_b, &remote_a), Resolution::KeepLocal));
525 + }
526 +
527 + #[test]
470 528 fn lww_newer_update_beats_older_delete() {
471 529 // The data-loss fix: a strictly-newer UPDATE must beat an older DELETE.
472 530 // Under the old "delete always wins" rule this edit was silently lost.
@@ -97,7 +97,7 @@ impl Hlc {
97 97 if now_ms > prev.wall_ms {
98 98 Hlc { wall_ms: now_ms, counter: 0, node }
99 99 } else {
100 - Hlc { wall_ms: prev.wall_ms, counter: prev.counter + 1, node }
100 + bump_counter(prev.wall_ms, prev.counter, node)
101 101 }
102 102 }
103 103
@@ -111,16 +111,28 @@ impl Hlc {
111 111 /// causally follow the remote change.
112 112 pub fn observe(prev: Hlc, remote: Hlc, now_ms: i64, node: Uuid) -> Hlc {
113 113 let wall = prev.wall_ms.max(remote.wall_ms).max(now_ms);
114 - let counter = if wall == prev.wall_ms && wall == remote.wall_ms {
115 - prev.counter.max(remote.counter) + 1
114 + let base = if wall == prev.wall_ms && wall == remote.wall_ms {
115 + prev.counter.max(remote.counter)
116 116 } else if wall == prev.wall_ms {
117 - prev.counter + 1
117 + prev.counter
118 118 } else if wall == remote.wall_ms {
119 - remote.counter + 1
119 + remote.counter
120 120 } else {
121 - 0
121 + // Physical time strictly leads both clocks: counter resets cleanly.
122 + return Hlc { wall_ms: wall, counter: 0, node };
122 123 };
123 - Hlc { wall_ms: wall, counter, node }
124 + bump_counter(wall, base, node)
125 + }
126 + }
127 +
128 + /// Increment a counter at `wall_ms`, rolling into the wall component on the
129 + /// (astronomically unlikely, but attacker-reachable via a hostile peer's
130 + /// `counter: u32::MAX`) overflow instead of wrapping to 0 — which would send the
131 + /// clock *backwards* and break the monotonicity HLC exists to guarantee.
132 + fn bump_counter(wall_ms: i64, counter: u32, node: Uuid) -> Hlc {
133 + match counter.checked_add(1) {
134 + Some(next) => Hlc { wall_ms, counter: next, node },
135 + None => Hlc { wall_ms: wall_ms.saturating_add(1), counter: 0, node },
124 136 }
125 137 }
126 138
@@ -374,6 +386,11 @@ pub(crate) struct RotationEntriesResponse {
374 386 #[derive(Deserialize)]
375 387 pub(crate) struct RotationEntry {
376 388 pub seq: i64,
389 + /// Source table — echoed by the server so re-encryption can recompute the
390 + /// entry's AEAD associated data (`aad_for_entry(table, row_id)`).
391 + pub table: String,
392 + /// App-assigned row identifier — see [`Self::table`].
393 + pub row_id: String,
377 394 pub data: Option<serde_json::Value>,
378 395 }
379 396
@@ -727,6 +744,30 @@ mod tests {
727 744 }
728 745
729 746 #[test]
747 + fn hlc_counter_overflow_rolls_into_wall_not_backwards() {
748 + let me = Uuid::from_u128(1);
749 + // A local tick at a saturated counter must not wrap to 0 (which would
750 + // move the clock backwards); it rolls the wall component forward instead.
751 + let prev = Hlc { wall_ms: 1000, counter: u32::MAX, node: me };
752 + let next = Hlc::tick(prev, 1000, me);
753 + assert!(next > prev, "tick at counter::MAX must stay strictly increasing");
754 + assert_eq!(next, Hlc { wall_ms: 1001, counter: 0, node: me });
755 + }
756 +
757 + #[test]
758 + fn hlc_observe_hostile_max_counter_stays_monotonic() {
759 + let me = Uuid::from_u128(1);
760 + let them = Uuid::from_u128(2);
761 + // A hostile/buggy peer sends counter: u32::MAX at our exact wall_ms.
762 + let local = Hlc { wall_ms: 5000, counter: 3, node: me };
763 + let remote = Hlc { wall_ms: 5000, counter: u32::MAX, node: them };
764 + let merged = Hlc::observe(local, remote, 5000, me);
765 + assert!(merged > local, "observe must not wrap backwards on a MAX remote counter");
766 + assert!(merged > remote);
767 + assert_eq!(merged, Hlc { wall_ms: 5001, counter: 0, node: me });
768 + }
769 +
770 + #[test]
730 771 fn hlc_serde_roundtrip() {
731 772 let h = Hlc { wall_ms: 123, counter: 4, node: Uuid::from_u128(7) };
732 773 let j = serde_json::to_value(h).unwrap();