Skip to main content

max / makenotwork

synckit: typed WireVersion envelope dispatch, reject unknown versions loudly Deep A+ pass, sync axis (D5). split_hlc_envelope keyed off __skhlc presence and silently fell back to a bare-row read, so a future envelope format would be misdecoded and corrupt the clock (X2). Dispatch is now explicit on the __skver tag via a typed WireVersion: an unknown future version is a hard error, gen-1 (tag-less) envelopes still parse, and a true bare row still synthesizes its HLC. Wire format unchanged. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Author: Max Johnson <me@maxj.phd> · 2026-06-22 22:42 UTC
Commit: 7971fa83de02ba170ac7c714496897f8b31f2c28
Parent: 915078f
1 file changed, +110 insertions, -19 deletions
@@ -26,6 +26,32 @@ pub(super) enum Idempotency {
26 26 Keyed,
27 27 }
28 28
29 + /// Current on-wire HLC-envelope version, written into the `__skver` tag.
30 + const ENVELOPE_VERSION: u64 = 2;
31 +
32 + /// The on-wire HLC-envelope version, parsed from the `__skver` tag.
33 + ///
34 + /// Parsing is the explicit dispatch point: an unknown (future) version is a
35 + /// hard error, never a silent fallback. That closes the X2 hazard — a protocol
36 + /// bump that an older client cannot understand fails loudly instead of being
37 + /// mis-decoded as a bare row and corrupting the clock.
38 + #[derive(Clone, Copy, PartialEq, Eq, Debug)]
39 + enum WireVersion {
40 + /// The current envelope: `{ __skver: 2, __skhlc, data }`.
41 + V2,
42 + }
43 +
44 + impl WireVersion {
45 + fn parse(tag: u64) -> Result<Self> {
46 + match tag {
47 + 2 => Ok(WireVersion::V2),
48 + other => Err(SyncKitError::Crypto(format!(
49 + "unknown sync envelope version {other}; this client is too old to read it"
50 + ))),
51 + }
52 + }
53 + }
54 +
29 55 impl SyncKitClient {
30 56 /// Retry an async HTTP operation with exponential backoff.
31 57 ///
@@ -169,34 +195,59 @@ impl SyncKitClient {
169 195 /// the E2E ciphertext — including for Deletes, which have no row payload. The
170 196 /// server stores the ciphertext opaquely and never sees the clock.
171 197 ///
172 - /// `__skver: 2` is a positive version marker that, together with the `sk2:`
173 - /// wire tag on the ciphertext, removes the need to structurally guess whether
174 - /// a decrypted value is an envelope or a bare legacy row.
198 + /// The `__skver` tag is a positive, explicit version marker. A reader
199 + /// dispatches on it (see [`WireVersion`]) rather than structurally guessing,
200 + /// so a future format bump is rejected loudly instead of silently misread.
175 201 fn hlc_envelope(hlc: &Hlc, data: &Option<serde_json::Value>) -> serde_json::Value {
176 - serde_json::json!({ "__skver": 2, "__skhlc": hlc, "data": data })
202 + serde_json::json!({ "__skver": ENVELOPE_VERSION, "__skhlc": hlc, "data": data })
177 203 }
178 204
179 - /// Split a decrypted payload back into `(hlc, data)`. A payload carrying an
180 - /// embedded `__skhlc` that parses as an [`Hlc`] is an envelope (gen1/gen2);
181 - /// anything else is a legacy bare-row payload whose HLC is synthesized from
182 - /// `node` + `timestamp_ms`. Detection keys on the embedded HLC's presence —
183 - /// not on a generic `data` key — so an app row that merely happens to contain
184 - /// a `data` field is never misread as an envelope.
205 + /// Split a decrypted payload back into `(hlc, data)`.
206 + ///
207 + /// Dispatch is explicit, not structural:
208 + /// - A `__skver` tag means a versioned envelope; the version is parsed via
209 + /// [`WireVersion::parse`], which **errors loudly** on an unknown future
210 + /// version rather than silently falling back to a bare-row read (which
211 + /// would corrupt the clock — the X2 hazard).
212 + /// - No `__skver` but an embedded `__skhlc` that parses is a gen-1 envelope
213 + /// (predates the version tag).
214 + /// - Anything else is a legacy bare-row payload whose HLC is synthesized
215 + /// from `node` + `timestamp_ms`.
185 216 fn split_hlc_envelope(
186 217 decrypted: serde_json::Value,
187 218 node: uuid::Uuid,
188 219 timestamp_ms: i64,
189 - ) -> (Hlc, Option<serde_json::Value>) {
190 - if let Some(obj) = decrypted.as_object()
191 - && let Some(hlc) = obj
220 + ) -> Result<(Hlc, Option<serde_json::Value>)> {
221 + if let Some(obj) = decrypted.as_object() {
222 + if let Some(tag) = obj.get("__skver") {
223 + // Explicit version present: dispatch, rejecting unknown loudly.
224 + let tag = tag.as_u64().ok_or_else(|| {
225 + SyncKitError::Crypto("envelope __skver tag is not an integer".into())
226 + })?;
227 + return match WireVersion::parse(tag)? {
228 + WireVersion::V2 => {
229 + let hlc = obj
230 + .get("__skhlc")
231 + .and_then(|v| serde_json::from_value::<Hlc>(v.clone()).ok())
232 + .ok_or_else(|| {
233 + SyncKitError::Crypto("v2 envelope missing __skhlc".into())
234 + })?;
235 + let data = obj.get("data").cloned().filter(|v| !v.is_null());
236 + Ok((hlc, data))
237 + }
238 + };
239 + }
240 + // gen-1 envelope: embedded HLC, no version tag.
241 + if let Some(hlc) = obj
192 242 .get("__skhlc")
193 243 .and_then(|v| serde_json::from_value::<Hlc>(v.clone()).ok())
194 - {
195 - let data = obj.get("data").cloned().filter(|v| !v.is_null());
196 - return (hlc, data);
244 + {
245 + let data = obj.get("data").cloned().filter(|v| !v.is_null());
246 + return Ok((hlc, data));
247 + }
197 248 }
198 249 // Legacy bare-row payload: the decrypted value is the row data itself.
199 - (Hlc::from_legacy(timestamp_ms, node), Some(decrypted))
250 + Ok((Hlc::from_legacy(timestamp_ms, node), Some(decrypted)))
200 251 }
201 252
202 253 /// Encrypt with a pre-loaded key. Used by `push()` to avoid per-entry lock
@@ -275,7 +326,7 @@ impl SyncKitClient {
275 326 let (hlc, data) = match entry.data {
276 327 Some(ref value) => {
277 328 let decrypted = crypto::decrypt_json_aad(value, master_key, &ctx)?;
278 - Self::split_hlc_envelope(decrypted, entry.device_id.as_uuid(), entry.timestamp.timestamp_millis())
329 + Self::split_hlc_envelope(decrypted, entry.device_id.as_uuid(), entry.timestamp.timestamp_millis())?
279 330 }
280 331 None => (
281 332 Hlc::from_legacy(entry.timestamp.timestamp_millis(), entry.device_id.as_uuid()),
@@ -299,7 +350,7 @@ impl SyncKitClient {
299 350 let (hlc, data) = match entry.data {
300 351 Some(ref value) => {
301 352 let decrypted = crypto::decrypt_json_aad(value, master_key, &ctx)?;
302 - Self::split_hlc_envelope(decrypted, entry.device_id.as_uuid(), entry.timestamp.timestamp_millis())
353 + Self::split_hlc_envelope(decrypted, entry.device_id.as_uuid(), entry.timestamp.timestamp_millis())?
303 354 }
304 355 None => (
305 356 Hlc::from_legacy(entry.timestamp.timestamp_millis(), entry.device_id.as_uuid()),
@@ -458,6 +509,46 @@ mod tests {
458 509 format!("{header}.{payload}.{signature}")
459 510 }
460 511
512 + // ── wire-version envelope dispatch ──
513 +
514 + #[test]
515 + fn split_envelope_dispatches_on_explicit_version() {
516 + let node = uuid::Uuid::from_u128(1);
517 + let hlc = Hlc { wall_ms: 5, counter: 2, node };
518 +
519 + // v2 envelope: explicit __skver, parsed by version.
520 + let v2 = serde_json::json!({ "__skver": 2, "__skhlc": hlc, "data": {"k": "v"} });
521 + let (got, data) = SyncKitClient::split_hlc_envelope(v2, node, 0).unwrap();
522 + assert_eq!(got, hlc);
523 + assert_eq!(data, Some(serde_json::json!({"k": "v"})));
524 +
525 + // gen-1 envelope: __skhlc present, no version tag.
526 + let gen1 = serde_json::json!({ "__skhlc": hlc, "data": null });
527 + let (got, data) = SyncKitClient::split_hlc_envelope(gen1, node, 0).unwrap();
528 + assert_eq!(got, hlc);
529 + assert_eq!(data, None);
530 +
531 + // Bare legacy row: HLC synthesized from node + timestamp.
532 + let bare = serde_json::json!({ "title": "buy milk" });
533 + let (got, data) = SyncKitClient::split_hlc_envelope(bare.clone(), node, 1234).unwrap();
534 + assert_eq!(got, Hlc::from_legacy(1234, node));
535 + assert_eq!(data, Some(bare));
536 + }
537 +
538 + #[test]
539 + fn split_envelope_rejects_unknown_version_loudly() {
540 + // The X2 hazard: a future envelope version must error, not silently
541 + // fall back to a bare-row read (which would corrupt the clock).
542 + let node = uuid::Uuid::from_u128(1);
543 + let hlc = Hlc { wall_ms: 5, counter: 0, node };
544 + let future = serde_json::json!({ "__skver": 3, "__skhlc": hlc, "data": null });
545 + let err = SyncKitClient::split_hlc_envelope(future, node, 0).unwrap_err();
546 + assert!(
547 + matches!(err, SyncKitError::Crypto(ref m) if m.contains("envelope version 3")),
548 + "unexpected error: {err:?}"
549 + );
550 + }
551 +
461 552 // ── encrypt_change / decrypt_change ──
462 553
463 554 #[test]