Skip to main content

max / makenotwork

synckit: bump to 0.5.0; document deep A+ wire formats and the clean-change gate Deep A+ pass, close-out. Minor bump reflecting the v0.5.0 surface (typed IDs, AeadContext, __skver dispatch, sk3 chunked blobs, CleanChanges gate, retry idempotency proof). architecture.md documents the new AAD context, envelope version dispatch, chunked blob format, and the gated clean-change apply contract; version reconciled to 0.5.0. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Author: Max Johnson <me@maxj.phd> · 2026-06-22 23:11 UTC
Commit: b7eadd1ee7da4c8570b4b56c284c9b0b8430cab9
Parent: 16c40bc
3 files changed, +36 insertions, -6 deletions
@@ -1520,7 +1520,7 @@ dependencies = [
1520 1520
1521 1521 [[package]]
1522 1522 name = "synckit-client"
1523 - version = "0.4.0"
1523 + version = "0.5.0"
1524 1524 dependencies = [
1525 1525 "argon2",
1526 1526 "base64",
@@ -1,6 +1,6 @@
1 1 [package]
2 2 name = "synckit-client"
3 - version = "0.4.0"
3 + version = "0.5.0"
4 4 edition = "2024"
5 5 description = "SyncKit client SDK with end-to-end encryption"
6 6 license-file = "LICENSE"
@@ -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.4.0.
10 + Version: 0.5.0.
11 11
12 12 ## Crate structure
13 13
@@ -154,6 +154,30 @@ to a different row or table: the AAD no longer matches and the open fails closed
154 154 Legacy and v2 ciphertext coexist with no flag-day; key rotation re-encrypts
155 155 legacy entries into the v2 form opportunistically.
156 156
157 + The AAD is never passed as raw bytes. Callers name a context -- `AeadContext::Entry
158 + { table, row_id }` or `AeadContext::Blob { hash }` -- and the crypto layer derives
159 + the AAD from it. The byte-level builder is private, so an empty or mismatched AAD
160 + is not expressible at the call site.
161 +
162 + ### Envelope version dispatch (`__skver`)
163 +
164 + The decrypted entry payload is an envelope `{ __skver: 2, __skhlc, data }`.
165 + Splitting it back into `(hlc, data)` dispatches on the explicit `__skver` tag via
166 + a typed `WireVersion`: an unknown future version is a hard error, not a silent
167 + fall-through to a bare-row read. A tag-less payload carrying an embedded `__skhlc`
168 + is a gen-1 envelope; anything else is a legacy bare row.
169 +
170 + ### Chunked blob format (`sk3:`)
171 +
172 + Blobs use a chunked AEAD format so a reader decrypts and verifies one chunk at a
173 + time. Layout: `sk3:` `version(u8=3)` `chunk_size(u32 LE)` `total_len(u64 LE)`
174 + followed by sealed 1 MiB chunks. Each chunk's AAD binds `(content_hash,
175 + chunk_index, chunk_count)`, so reorder, truncation, duplication, and cross-blob
176 + substitution all fail closed. `blob_download` streams the body and decrypts
177 + chunk-by-chunk (peak memory is the plaintext plus one in-flight chunk, not
178 + ciphertext *and* plaintext together); legacy `sk2:`/untagged blobs still decrypt
179 + whole-buffer with no flag-day.
180 +
157 181 ### Push
158 182
159 183 1. Caller provides a `Vec<ChangeEntry>` with plaintext `data` fields and an HLC.
@@ -188,9 +212,15 @@ counter, node)` wins, so a newer edit beats an older delete and vice-versa, and
188 212 every device converges on the same winner. An *exactly* equal HLC normally means
189 213 a same-device echo; if two installs share a `node` UUID (cloned config), the tie
190 214 is broken deterministically on the canonical payload bytes so both devices still
191 - converge. `detect_conflicts` only flags rows with an *un-pushed* local edit;
192 - "clean" changes must still be HLC-gated by the caller against committed local
193 - state before applying.
215 + converge.
216 +
217 + `detect_conflicts` only flags rows with an *un-pushed* local edit. Its clean set
218 + is returned as an opaque `CleanChanges`, not a bare `Vec`: the only way to read
219 + the applicable entries out is `gated(committed_hlc)`, which drops any clean change
220 + older-or-equal to the row's committed HLC. (`row_keys()` lets the caller pre-fetch
221 + those clocks first.) This makes "apply a clean change without checking the
222 + committed clock" — which would let an older remote edit clobber a newer local
223 + value — unrepresentable rather than a contract the caller has to remember.
194 224
195 225 ### Sequence numbers
196 226