Compare commits

..

2 Commits

14 changed files with 1895 additions and 7 deletions

388
Cargo.lock generated
View File

@ -2,6 +2,12 @@
# It is not intended for manual editing.
version = 3
[[package]]
name = "adler2"
version = "2.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa"
[[package]]
name = "ahash"
version = "0.8.12"
@ -26,6 +32,29 @@ dependencies = [
"password-hash",
]
[[package]]
name = "automerge"
version = "0.5.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1f0dae93622d3c6850d196503480004576249e0e391bddb3f54600974d92a790"
dependencies = [
"cfg-if",
"flate2",
"fxhash",
"hex",
"im",
"itertools",
"leb128",
"serde",
"sha2",
"smol_str",
"thiserror 1.0.69",
"tinyvec",
"tracing",
"unicode-segmentation",
"uuid",
]
[[package]]
name = "base64ct"
version = "1.8.3"
@ -38,6 +67,15 @@ version = "2.13.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b588b76d00fde79687d7646a9b5bdf3cc0f655e0bbd080335a95d7e96f3587da"
[[package]]
name = "bitmaps"
version = "2.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "031043d04099746d8db04daf1fa424b2bc8bd69d92b25962dcde24da39ab64a2"
dependencies = [
"typenum",
]
[[package]]
name = "blake2"
version = "0.10.6"
@ -56,6 +94,12 @@ dependencies = [
"generic-array",
]
[[package]]
name = "bumpalo"
version = "3.20.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649"
[[package]]
name = "byteorder"
version = "1.5.0"
@ -86,9 +130,11 @@ version = "0.1.0"
name = "chronogrid_core"
version = "0.1.0"
dependencies = [
"automerge",
"rusqlite",
"tempfile",
"thiserror",
"thiserror 2.0.20",
"uuid",
]
[[package]]
@ -98,7 +144,7 @@ dependencies = [
"argon2",
"keyring",
"rand",
"thiserror",
"thiserror 2.0.20",
"zeroize",
]
@ -141,6 +187,15 @@ dependencies = [
"libc",
]
[[package]]
name = "crc32fast"
version = "1.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9481c1c90cbf2ac953f07c8d4a58aa3945c425b7185c9154d67a65e4230da511"
dependencies = [
"cfg-if",
]
[[package]]
name = "crypto-common"
version = "0.1.7"
@ -183,6 +238,12 @@ dependencies = [
"subtle",
]
[[package]]
name = "either"
version = "1.18.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "252afb9ae5eaa683babdc6a068b3f5726eb19e05070c731f9b2a23a7c3e8ed34"
[[package]]
name = "errno"
version = "0.3.14"
@ -217,6 +278,49 @@ version = "0.1.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d45db016d36b838f563236e9193d0ee6ce38f3f68b6c94e914b4929c96bbb890"
[[package]]
name = "flate2"
version = "1.1.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "843fba2746e448b37e26a819579957415c8cef339bf08564fe8b7ddbd959573c"
dependencies = [
"crc32fast",
"miniz_oxide",
]
[[package]]
name = "futures-core"
version = "0.3.34"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "92d699e522242e69e3003b94ecc1f960f3a5e015aa7c5d7486e65ad01dd94f5e"
[[package]]
name = "futures-task"
version = "0.3.34"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cd417de3d1d015fc3bfd2b1ea46dfc7bab72ef86f1cc7cc9c78e728b34a6d1fd"
[[package]]
name = "futures-util"
version = "0.3.34"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0d50a92467f8ba5dd6e3ee5d4bd04d73ab2e4e1c44474a0674821dfce14b79bc"
dependencies = [
"futures-core",
"futures-task",
"pin-project-lite",
"slab",
]
[[package]]
name = "fxhash"
version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c31b6d751ae2c7f11320402d34e41349dd1016f8d5d45e48c4312bc8625af50c"
dependencies = [
"byteorder",
]
[[package]]
name = "generic-array"
version = "0.14.7"
@ -267,6 +371,46 @@ dependencies = [
"hashbrown",
]
[[package]]
name = "hex"
version = "0.4.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70"
[[package]]
name = "im"
version = "15.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d0acd33ff0285af998aaf9b57342af478078f53492322fafc47450e09397e0e9"
dependencies = [
"bitmaps",
"rand_core",
"rand_xoshiro",
"sized-chunks",
"typenum",
"version_check",
]
[[package]]
name = "itertools"
version = "0.13.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "413ee7dfc52ee1a4949ceeb7dbc8a33f2d6c088194d9f922fb8318faf1f01186"
dependencies = [
"either",
]
[[package]]
name = "js-sys"
version = "0.3.104"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0e0c1080212aad755ea003d18543e8768dd432c48819efd73a7bf1e39b7a5a3a"
dependencies = [
"cfg-if",
"futures-util",
"wasm-bindgen",
]
[[package]]
name = "keyring"
version = "3.6.3"
@ -282,6 +426,12 @@ dependencies = [
"zeroize",
]
[[package]]
name = "leb128"
version = "0.2.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c83bff1d572d6b9aeef67ddfc8448e4a3737909cb28e81f97c791b9018703e52"
[[package]]
name = "libc"
version = "0.2.189"
@ -321,6 +471,16 @@ version = "0.4.33"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad"
[[package]]
name = "miniz_oxide"
version = "0.8.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316"
dependencies = [
"adler2",
"simd-adler32",
]
[[package]]
name = "once_cell"
version = "1.21.4"
@ -360,6 +520,12 @@ dependencies = [
"subtle",
]
[[package]]
name = "pin-project-lite"
version = "0.2.17"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd"
[[package]]
name = "pkg-config"
version = "0.3.33"
@ -429,6 +595,15 @@ dependencies = [
"getrandom 0.2.17",
]
[[package]]
name = "rand_xoshiro"
version = "0.6.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6f97cdb2a36ed4183de61b2f824cc45c9f1037f28afe0a322e9fff4c108b5aaa"
dependencies = [
"rand_core",
]
[[package]]
name = "rusqlite"
version = "0.31.0"
@ -456,6 +631,12 @@ dependencies = [
"windows-sys 0.61.2",
]
[[package]]
name = "rustversion"
version = "1.0.23"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f"
[[package]]
name = "security-framework"
version = "2.11.1"
@ -492,18 +673,90 @@ dependencies = [
"libc",
]
[[package]]
name = "serde"
version = "1.0.229"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba"
dependencies = [
"serde_core",
"serde_derive",
]
[[package]]
name = "serde_core"
version = "1.0.229"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48"
dependencies = [
"serde_derive",
]
[[package]]
name = "serde_derive"
version = "1.0.229"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348"
dependencies = [
"proc-macro2",
"quote",
"syn 3.0.3",
]
[[package]]
name = "sha2"
version = "0.10.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283"
dependencies = [
"cfg-if",
"cpufeatures",
"digest",
]
[[package]]
name = "shlex"
version = "2.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba"
[[package]]
name = "simd-adler32"
version = "0.3.10"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3a219298ac11a56ea9a6d2120044824d6f01aeb034955e7af7bc16858527deea"
[[package]]
name = "sized-chunks"
version = "0.6.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "16d69225bde7a69b235da73377861095455d298f2b970996eec25ddbb42b3d1e"
dependencies = [
"bitmaps",
"typenum",
]
[[package]]
name = "slab"
version = "0.4.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5"
[[package]]
name = "smallvec"
version = "1.15.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8ed6a63f02c8539c91a8685a86f4099661ba3da017932f6ebbea6de3f0fa7c90"
[[package]]
name = "smol_str"
version = "0.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dd538fb6910ac1099850255cf94a94df6551fbdd602454387d0adb2d1ca6dead"
dependencies = [
"serde",
]
[[package]]
name = "subtle"
version = "2.6.1"
@ -545,13 +798,33 @@ dependencies = [
"windows-sys 0.61.2",
]
[[package]]
name = "thiserror"
version = "1.0.69"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52"
dependencies = [
"thiserror-impl 1.0.69",
]
[[package]]
name = "thiserror"
version = "2.0.20"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ec86235f5fcc2a73650310756d2ac5b138a5780bbbdfae3eeccec992c435ba4f"
dependencies = [
"thiserror-impl",
"thiserror-impl 2.0.20",
]
[[package]]
name = "thiserror-impl"
version = "1.0.69"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.119",
]
[[package]]
@ -565,6 +838,52 @@ dependencies = [
"syn 3.0.3",
]
[[package]]
name = "tinyvec"
version = "1.12.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bb4ebadaa0af04fab11ae01eb5f9fdb5f9c5b875506e210e71c07873528baa7f"
dependencies = [
"tinyvec_macros",
]
[[package]]
name = "tinyvec_macros"
version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20"
[[package]]
name = "tracing"
version = "0.1.44"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100"
dependencies = [
"pin-project-lite",
"tracing-attributes",
"tracing-core",
]
[[package]]
name = "tracing-attributes"
version = "0.1.31"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.119",
]
[[package]]
name = "tracing-core"
version = "0.1.36"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a"
dependencies = [
"once_cell",
]
[[package]]
name = "typenum"
version = "1.20.1"
@ -577,6 +896,24 @@ version = "1.0.24"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75"
[[package]]
name = "unicode-segmentation"
version = "1.13.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c6f5d3c3b1bf09027a88a6bc961fc00497d651009560b5463668dc81b0fa87a8"
[[package]]
name = "uuid"
version = "1.24.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2cefc03fd367c0c6d4305de1b312cf00248c4114f4a0418ce6a6af769e3b0bd9"
dependencies = [
"getrandom 0.4.3",
"js-sys",
"serde_core",
"wasm-bindgen",
]
[[package]]
name = "vcpkg"
version = "0.2.15"
@ -595,6 +932,51 @@ version = "0.11.1+wasi-snapshot-preview1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b"
[[package]]
name = "wasm-bindgen"
version = "0.2.127"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1b70935747edd64d89de3efa29d73789b806c15798f8e7dca4d8ac356b50ce70"
dependencies = [
"cfg-if",
"once_cell",
"rustversion",
"wasm-bindgen-macro",
"wasm-bindgen-shared",
]
[[package]]
name = "wasm-bindgen-macro"
version = "0.2.127"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "77775f8f3f7217702089053b94958f8f54061a3f663417df76e19cbdcca29bc1"
dependencies = [
"quote",
"wasm-bindgen-macro-support",
]
[[package]]
name = "wasm-bindgen-macro-support"
version = "0.2.127"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e11d33f857dc2fb11b8bc75aee111aa9cbeb12cd9f25efd3d4c2a3dd4e235284"
dependencies = [
"bumpalo",
"proc-macro2",
"quote",
"syn 2.0.119",
"wasm-bindgen-shared",
]
[[package]]
name = "wasm-bindgen-shared"
version = "0.2.127"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7ef64dbcc55df09c7e5a46182d181c2cfa3e925f3da937ea764728b4bbb9dcbf"
dependencies = [
"unicode-ident",
]
[[package]]
name = "windows-link"
version = "0.2.1"

View File

@ -20,6 +20,20 @@
- `Database` struct provides `open(path, key)` for file-backed and `open_in_memory(key)` for test databases; key length is validated at 32 bytes.
- 11 unit tests cover: table/index creation, FK enforcement, cascade deletes, wrong-key rejection, key length validation, schema idempotency, and CRUD round-trips across all tables.
## [2026-08-20] - ADL-004: chronogrid_core – Automerge CRDT Integration for Task & Note Registers
- **Status**: Accepted
- **Context**: Task 1.4 required integrating the Automerge CRDT engine into `chronogrid_core` so task and note entities support offline-first LWW register mutations with HLC-based causal ordering.
- **Decision**:
- Added `automerge = "0.5"` (resolved to v0.5.12) and `uuid = "1"` with `v4` feature to `chronogrid_core/Cargo.toml`.
- Extended `schema.sql` with `crdt_docs` table (`entity_id TEXT PK`, `entity_type TEXT`, `doc_bytes BLOB`, `updated_at INTEGER`) and `idx_crdt_docs_type` index.
- Implemented `crdt/` sub-module: `Hlc` (Hybrid Logical Clock with `tick()`/`update()`/`Display`/`FromStr`), `CrdtDoc` (generic Automerge wrapper with typed scalar accessors), `TaskCrdt` (11 mutable fields), `NoteCrdt` (4 mutable fields).
- Each entity mutation stamps the document with an advancing HLC timestamp. Fork/merge delegates to Automerge's deterministic conflict resolution.
- `Database` gained `save_crdt_doc()`, `load_crdt_doc()`, `delete_crdt_doc()` for serialized Automerge blob persistence.
- Added `CoreError::Crdt` and `CoreError::HlcParse` error variants.
- 45 unit tests (all passing): HLC ordering/parse, all scalar types, save/load/fork/merge round-trips, CRDT doc DB persistence, schema validation with `crdt_docs` table.
- Full ADR at `docs/ARCHITECTURE/ADR/0004-core-automerge-crdt-integration.md`.
## [2026-08-09] - ADL-002: chronogrid_crypto Crate – Argon2id KDF & OS Keychain
- **Status**: Accepted

View File

@ -7,8 +7,10 @@ rust-version.workspace = true
publish = false
[dependencies]
rusqlite = { version = "0.31", features = ["bundled-sqlcipher-vendored-openssl"] }
automerge = "0.5"
rusqlite = { version = "0.31", features = ["bundled-sqlcipher-vendored-openssl", "blob"] }
thiserror = "2"
uuid = { version = "1", features = ["v4"] }
[dev-dependencies]
tempfile = "3"

View File

@ -0,0 +1,316 @@
//! Generic Automerge CRDT document wrapper.
//!
//! [`CrdtDoc`] provides a thin, ergonomic layer over [`automerge::AutoCommit`]
//! for storing LWW-Element-Set registers as key-value pairs at the document
//! root. Higher-level entity wrappers ([`super::task::TaskCrdt`],
//! [`super::note::NoteCrdt`]) delegate to `CrdtDoc` for typed field access.
use automerge::transaction::Transactable;
use automerge::{AutoCommit, ReadDoc, ScalarValue, Value, ROOT};
use crate::error::{CoreError, Result};
/// A thin wrapper around an Automerge `AutoCommit` document.
///
/// All mutable fields are stored as scalar values in a flat map at the
/// document root (`ROOT`). This enables Automerge's built-in LWW conflict
/// resolution for concurrent writes.
pub struct CrdtDoc {
inner: AutoCommit,
}
impl std::fmt::Debug for CrdtDoc {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("CrdtDoc")
.field("inner", &"<AutoCommit>")
.finish()
}
}
impl CrdtDoc {
// ── Construction ─────────────────────────────────────────────────────
/// Creates a new, empty Automerge document.
pub fn new() -> Self {
Self {
inner: AutoCommit::new(),
}
}
/// Loads a document from its serialized byte representation.
pub fn load(bytes: &[u8]) -> Result<Self> {
let inner = AutoCommit::load(bytes).map_err(|e| {
CoreError::Crdt(format!("failed to load Automerge document: {e}"))
})?;
Ok(Self { inner })
}
/// Serializes the document to a compact byte representation.
pub fn save(&mut self) -> Vec<u8> {
self.inner.save()
}
/// Forks the document, producing an independent copy that can be
/// modified and later merged back.
pub fn fork(&mut self) -> Self {
Self {
inner: self.inner.fork(),
}
}
/// Merges another document into this one. Automerge resolves any
/// concurrent writes deterministically via its internal LWW semantics.
pub fn merge(&mut self, other: &mut CrdtDoc) -> Result<()> {
self.inner.merge(&mut other.inner).map_err(|e| {
CoreError::Crdt(format!("merge failed: {e}"))
})?;
Ok(())
}
/// Returns a reference to the underlying `AutoCommit` for advanced use.
#[inline]
pub fn inner(&self) -> &AutoCommit {
&self.inner
}
/// Returns a mutable reference to the underlying `AutoCommit`.
#[inline]
pub fn inner_mut(&mut self) -> &mut AutoCommit {
&mut self.inner
}
// ── Scalar setters (LWW registers at ROOT) ──────────────────────────
/// Stores a string value at the given key.
pub fn put_str(&mut self, key: &str, value: &str) -> Result<()> {
self.inner
.put(&ROOT, key, value)
.map_err(|e| CoreError::Crdt(format!("put_str({key}): {e}")))?;
Ok(())
}
/// Stores an `f64` value at the given key.
pub fn put_f64(&mut self, key: &str, value: f64) -> Result<()> {
self.inner
.put(&ROOT, key, value)
.map_err(|e| CoreError::Crdt(format!("put_f64({key}): {e}")))?;
Ok(())
}
/// Stores an `i64` value at the given key.
pub fn put_i64(&mut self, key: &str, value: i64) -> Result<()> {
self.inner
.put(&ROOT, key, ScalarValue::Int(value))
.map_err(|e| CoreError::Crdt(format!("put_i64({key}): {e}")))?;
Ok(())
}
/// Stores a `u64` value at the given key.
pub fn put_u64(&mut self, key: &str, value: u64) -> Result<()> {
self.inner
.put(&ROOT, key, ScalarValue::Uint(value))
.map_err(|e| CoreError::Crdt(format!("put_u64({key}): {e}")))?;
Ok(())
}
/// Stores a boolean value at the given key.
pub fn put_bool(&mut self, key: &str, value: bool) -> Result<()> {
self.inner
.put(&ROOT, key, value)
.map_err(|e| CoreError::Crdt(format!("put_bool({key}): {e}")))?;
Ok(())
}
/// Removes a key from the document root (sets it to the Automerge
/// equivalent of "deleted").
pub fn delete(&mut self, key: &str) -> Result<()> {
self.inner
.delete(&ROOT, key)
.map_err(|e| CoreError::Crdt(format!("delete({key}): {e}")))?;
Ok(())
}
// ── Scalar getters ──────────────────────────────────────────────────
/// Retrieves a string value, returning `None` if the key is absent or
/// has a non-string type.
pub fn get_str(&self, key: &str) -> Result<Option<String>> {
match self.get_value(key)? {
Some(Value::Scalar(cow)) => match cow.as_ref() {
ScalarValue::Str(s) => Ok(Some(s.to_string())),
_ => Ok(None),
},
_ => Ok(None),
}
}
/// Retrieves an `f64` value.
pub fn get_f64(&self, key: &str) -> Result<Option<f64>> {
match self.get_value(key)? {
Some(Value::Scalar(cow)) => match cow.as_ref() {
ScalarValue::F64(v) => Ok(Some(*v)),
_ => Ok(None),
},
_ => Ok(None),
}
}
/// Retrieves an `i64` value.
pub fn get_i64(&self, key: &str) -> Result<Option<i64>> {
match self.get_value(key)? {
Some(Value::Scalar(cow)) => match cow.as_ref() {
ScalarValue::Int(v) => Ok(Some(*v)),
_ => Ok(None),
},
_ => Ok(None),
}
}
/// Retrieves a `u64` value.
pub fn get_u64(&self, key: &str) -> Result<Option<u64>> {
match self.get_value(key)? {
Some(Value::Scalar(cow)) => match cow.as_ref() {
ScalarValue::Uint(v) => Ok(Some(*v)),
_ => Ok(None),
},
_ => Ok(None),
}
}
/// Retrieves a boolean value.
pub fn get_bool(&self, key: &str) -> Result<Option<bool>> {
match self.get_value(key)? {
Some(Value::Scalar(cow)) => match cow.as_ref() {
ScalarValue::Boolean(v) => Ok(Some(*v)),
_ => Ok(None),
},
_ => Ok(None),
}
}
/// Returns `true` if the key exists (has any value) at the document root.
pub fn has_key(&self, key: &str) -> Result<bool> {
Ok(self.get_value(key)?.is_some())
}
// ── Private helpers ─────────────────────────────────────────────────
/// Low-level getter returning the raw Automerge [`Value`].
fn get_value(&self, key: &str) -> Result<Option<Value<'_>>> {
let result = self
.inner
.get(&ROOT, key)
.map_err(|e| CoreError::Crdt(format!("get({key}): {e}")))?;
Ok(result.map(|(value, _obj_id)| value))
}
}
// ── Tests ────────────────────────────────────────────────────────────────────
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn put_get_str_round_trip() {
let mut doc = CrdtDoc::new();
doc.put_str("title", "Hello CRDT").unwrap();
assert_eq!(doc.get_str("title").unwrap(), Some("Hello CRDT".into()));
assert_eq!(doc.get_str("missing").unwrap(), None);
}
#[test]
fn put_get_f64_round_trip() {
let mut doc = CrdtDoc::new();
doc.put_f64("score", 3.14).unwrap();
let val = doc.get_f64("score").unwrap().unwrap();
assert!((val - 3.14).abs() < f64::EPSILON);
}
#[test]
fn put_get_i64_round_trip() {
let mut doc = CrdtDoc::new();
doc.put_i64("quadrant", 2).unwrap();
assert_eq!(doc.get_i64("quadrant").unwrap(), Some(2));
}
#[test]
fn put_get_u64_round_trip() {
let mut doc = CrdtDoc::new();
doc.put_u64("due_date", 1_700_000_000_000).unwrap();
assert_eq!(doc.get_u64("due_date").unwrap(), Some(1_700_000_000_000));
}
#[test]
fn put_get_bool_round_trip() {
let mut doc = CrdtDoc::new();
doc.put_bool("archived", true).unwrap();
assert_eq!(doc.get_bool("archived").unwrap(), Some(true));
}
#[test]
fn delete_removes_key() {
let mut doc = CrdtDoc::new();
doc.put_str("key", "value").unwrap();
assert!(doc.has_key("key").unwrap());
doc.delete("key").unwrap();
assert!(!doc.has_key("key").unwrap());
}
#[test]
fn save_and_load_round_trip() {
let mut doc = CrdtDoc::new();
doc.put_str("title", "Persist me").unwrap();
doc.put_i64("count", 42).unwrap();
let bytes = doc.save();
let loaded = CrdtDoc::load(&bytes).unwrap();
assert_eq!(loaded.get_str("title").unwrap(), Some("Persist me".into()));
assert_eq!(loaded.get_i64("count").unwrap(), Some(42));
}
#[test]
fn fork_and_merge_concurrent_edits() {
let mut doc = CrdtDoc::new();
doc.put_str("title", "Original").unwrap();
// Fork and make divergent edits.
let mut fork = doc.fork();
doc.put_str("title", "From peer A").unwrap();
fork.put_str("title", "From peer B").unwrap();
// Merge: Automerge deterministically picks a winner.
doc.merge(&mut fork).unwrap();
let title = doc.get_str("title").unwrap();
assert!(title.is_some(), "title must have a resolved value after merge");
}
#[test]
fn fork_and_merge_non_conflicting_keys() {
let mut doc = CrdtDoc::new();
let mut fork = doc.fork();
doc.put_str("key_a", "value A").unwrap();
fork.put_str("key_b", "value B").unwrap();
doc.merge(&mut fork).unwrap();
assert_eq!(doc.get_str("key_a").unwrap(), Some("value A".into()));
assert_eq!(doc.get_str("key_b").unwrap(), Some("value B".into()));
}
#[test]
fn load_garbage_bytes_fails() {
let result = CrdtDoc::load(b"not valid automerge data");
assert!(result.is_err());
}
}

View File

@ -0,0 +1,265 @@
//! Hybrid Logical Clock (HLC) implementation.
//!
//! Combines physical wall-clock time (milliseconds since Unix epoch) with a
//! logical counter and a unique node identifier to produce causally ordered,
//! globally unique timestamps suitable for LWW-Element-Set CRDT registers.
//!
//! The serialized form `{wall_ms:016}-{counter:08x}-{node_id}` is stored in
//! the `hlc_timestamp TEXT` columns of `tasks`, `notes`, and `projects`.
//! Lexicographic comparison of these strings matches the logical ordering.
use std::fmt;
use std::str::FromStr;
use std::time::{SystemTime, UNIX_EPOCH};
use crate::error::{CoreError, Result};
/// A Hybrid Logical Clock timestamp.
///
/// Ordering: first by `wall_ms`, then by `counter`, then by `node_id`.
/// This ensures deterministic conflict resolution across peers.
#[derive(Debug, Clone, Eq, PartialEq)]
pub struct Hlc {
/// Physical wall-clock time in milliseconds since Unix epoch.
wall_ms: u64,
/// Logical counter for disambiguation when wall_ms values collide.
counter: u32,
/// Unique node identifier (typically a hex-encoded UUID).
node_id: String,
}
impl Hlc {
/// Creates a new HLC seeded with the current system time.
pub fn new(node_id: impl Into<String>) -> Self {
Self {
wall_ms: now_ms(),
counter: 0,
node_id: node_id.into(),
}
}
/// Creates an HLC with explicit values (useful for testing or deserialization).
pub fn from_parts(wall_ms: u64, counter: u32, node_id: impl Into<String>) -> Self {
Self {
wall_ms,
counter,
node_id: node_id.into(),
}
}
/// Advances the clock for a local event and returns the new timestamp.
///
/// If the system clock has advanced beyond `wall_ms`, the counter resets.
/// Otherwise the counter is incremented to maintain causality.
pub fn tick(&mut self) -> Hlc {
let now = now_ms();
if now > self.wall_ms {
self.wall_ms = now;
self.counter = 0;
} else {
self.counter = self.counter.saturating_add(1);
}
self.clone()
}
/// Merges a remote HLC into this clock, advancing to maintain causality.
///
/// After this call the local HLC is guaranteed to be strictly greater
/// than both its previous value and the remote value.
pub fn update(&mut self, remote: &Hlc) -> Hlc {
let now = now_ms();
if now > self.wall_ms && now > remote.wall_ms {
self.wall_ms = now;
self.counter = 0;
} else if self.wall_ms == remote.wall_ms {
self.counter = self.counter.max(remote.counter).saturating_add(1);
} else if self.wall_ms > remote.wall_ms {
self.counter = self.counter.saturating_add(1);
} else {
// remote.wall_ms > self.wall_ms
self.wall_ms = remote.wall_ms;
self.counter = remote.counter.saturating_add(1);
}
self.clone()
}
/// Returns the physical timestamp component in milliseconds since Unix epoch.
#[inline]
pub fn wall_ms(&self) -> u64 {
self.wall_ms
}
/// Returns the logical counter component.
#[inline]
pub fn counter(&self) -> u32 {
self.counter
}
/// Returns the node identifier component.
#[inline]
pub fn node_id(&self) -> &str {
&self.node_id
}
}
// ── Ordering ─────────────────────────────────────────────────────────────────
impl Ord for Hlc {
fn cmp(&self, other: &Self) -> std::cmp::Ordering {
self.wall_ms
.cmp(&other.wall_ms)
.then_with(|| self.counter.cmp(&other.counter))
.then_with(|| self.node_id.cmp(&other.node_id))
}
}
impl PartialOrd for Hlc {
fn partial_cmp(&self, other: &Self) -> Option<std::cmp::Ordering> {
Some(self.cmp(other))
}
}
// ── Display / FromStr ────────────────────────────────────────────────────────
/// Serialization format: `{wall_ms:016}-{counter:08x}-{node_id}`
///
/// The zero-padded numeric fields ensure that lexicographic string comparison
/// matches the logical [`Ord`] implementation.
impl fmt::Display for Hlc {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "{:016}-{:08x}-{}", self.wall_ms, self.counter, self.node_id)
}
}
impl FromStr for Hlc {
type Err = CoreError;
fn from_str(s: &str) -> Result<Self> {
let parts: Vec<&str> = s.splitn(3, '-').collect();
if parts.len() != 3 {
return Err(CoreError::HlcParse(format!(
"expected 3 dash-separated parts, got {}: '{s}'",
parts.len()
)));
}
let wall_ms = parts[0].parse::<u64>().map_err(|e| {
CoreError::HlcParse(format!("invalid wall_ms '{}': {e}", parts[0]))
})?;
let counter = u32::from_str_radix(parts[1], 16).map_err(|e| {
CoreError::HlcParse(format!("invalid counter '{}': {e}", parts[1]))
})?;
let node_id = parts[2].to_owned();
if node_id.is_empty() {
return Err(CoreError::HlcParse("node_id must not be empty".into()));
}
Ok(Self {
wall_ms,
counter,
node_id,
})
}
}
// ── Helpers ──────────────────────────────────────────────────────────────────
/// Returns the current wall-clock time in milliseconds since Unix epoch.
fn now_ms() -> u64 {
SystemTime::now()
.duration_since(UNIX_EPOCH)
.expect("system clock before Unix epoch")
.as_millis() as u64
}
// ── Tests ────────────────────────────────────────────────────────────────────
#[cfg(test)]
mod tests {
use super::*;
const NODE_A: &str = "node-aaa";
const NODE_B: &str = "node-bbb";
#[test]
fn tick_advances_monotonically() {
let mut hlc = Hlc::new(NODE_A);
let t1 = hlc.tick();
let t2 = hlc.tick();
let t3 = hlc.tick();
assert!(t1 < t2, "t1 must be less than t2");
assert!(t2 < t3, "t2 must be less than t3");
}
#[test]
fn update_merges_remote_clock() {
let mut local = Hlc::from_parts(100, 0, NODE_A);
let remote = Hlc::from_parts(200, 5, NODE_B);
let merged = local.update(&remote);
// Local should have adopted remote's higher wall time.
assert!(merged.wall_ms >= 200);
assert!(merged > remote, "merged must be greater than remote");
}
#[test]
fn update_same_wall_time_increments_counter() {
// Use a wall_ms far in the future so now_ms() never overrides it.
let future_ms: u64 = 9_999_999_999_999;
let mut local = Hlc::from_parts(future_ms, 3, NODE_A);
let remote = Hlc::from_parts(future_ms, 5, NODE_B);
let merged = local.update(&remote);
assert_eq!(merged.wall_ms, future_ms);
assert_eq!(merged.counter, 6); // max(3,5) + 1
}
#[test]
fn display_and_parse_round_trip() {
let hlc = Hlc::from_parts(1_723_200_000_000, 42, "node-deadbeef");
let s = hlc.to_string();
// 1_723_200_000_000 zero-padded to 16 digits.
assert_eq!(s, "0001723200000000-0000002a-node-deadbeef");
let parsed: Hlc = s.parse().expect("parse should succeed");
assert_eq!(parsed, hlc);
}
#[test]
fn lexicographic_order_matches_logical_order() {
let a = Hlc::from_parts(100, 0, NODE_A);
let b = Hlc::from_parts(200, 0, NODE_A);
let c = Hlc::from_parts(200, 1, NODE_A);
// Logical ordering.
assert!(a < b);
assert!(b < c);
// String ordering must agree.
assert!(a.to_string() < b.to_string());
assert!(b.to_string() < c.to_string());
}
#[test]
fn parse_rejects_malformed_input() {
assert!("no-dashes-here".parse::<Hlc>().is_err());
assert!("abc-def".parse::<Hlc>().is_err()); // only 2 parts
assert!("not_a_number-00000000-node".parse::<Hlc>().is_err());
assert!("0000000000000100-zzzzzzzz-node".parse::<Hlc>().is_err()); // bad hex
assert!("0000000000000100-00000000-".parse::<Hlc>().is_err()); // empty node_id
}
#[test]
fn ordering_breaks_ties_by_node_id() {
let a = Hlc::from_parts(100, 0, "aaa");
let b = Hlc::from_parts(100, 0, "bbb");
assert!(a < b, "node_id 'aaa' < 'bbb'");
}
}

View File

@ -0,0 +1,30 @@
//! CRDT (Conflict-free Replicated Data Type) module for ChronoGrid P2P.
//!
//! This module integrates the [Automerge](https://automerge.org/) CRDT engine
//! with ChronoGrid's local-first storage layer. It provides:
//!
//! * [`hlc::Hlc`] — Hybrid Logical Clock for causally ordered timestamps.
//! * [`doc::CrdtDoc`] — Generic Automerge document wrapper with typed
//! scalar accessors.
//! * [`task::TaskCrdt`] — LWW register mutations for task entities.
//! * [`note::NoteCrdt`] — LWW register mutations for note entities.
//!
//! ## Design
//!
//! Each task or note has a corresponding Automerge document whose serialized
//! bytes are stored in the `crdt_docs` table alongside the primary SQL row.
//! Field mutations write LWW scalar values at the document root; concurrent
//! edits from different peers merge deterministically via Automerge's built-in
//! conflict resolution, with HLC timestamps providing additional causal
//! ordering context.
pub mod doc;
pub mod hlc;
pub mod note;
pub mod task;
// Re-export primary types at module level for ergonomic imports.
pub use doc::CrdtDoc;
pub use hlc::Hlc;
pub use note::NoteCrdt;
pub use task::TaskCrdt;

View File

@ -0,0 +1,290 @@
//! Note CRDT register — typed LWW mutations for note entity fields.
//!
//! [`NoteCrdt`] wraps a [`CrdtDoc`] and exposes strongly typed accessors for
//! every mutable note field defined in `docs/API_CONTRACTS/schema.sql`.
//!
//! ## Field Mapping
//!
//! | SQL Column | CRDT Key | Type |
//! |-------------------------|-----------------------|---------------|
//! | `title` | `title` | String (LWW) |
//! | `content_markdown` | `content_markdown` | String (LWW) |
//! | `namespace_id` | `namespace_id` | String (LWW) |
//! | `project_id` | `project_id` | String (LWW) |
use super::doc::CrdtDoc;
use super::hlc::Hlc;
use crate::error::Result;
/// CRDT key constants matching the schema column names.
mod keys {
pub const ENTITY_ID: &str = "entity_id";
pub const ENTITY_TYPE: &str = "entity_type";
pub const NAMESPACE_ID: &str = "namespace_id";
pub const PROJECT_ID: &str = "project_id";
pub const TITLE: &str = "title";
pub const CONTENT_MD: &str = "content_markdown";
pub const HLC_TIMESTAMP: &str = "hlc_timestamp";
}
/// Typed CRDT wrapper for a **note** entity.
///
/// Every field mutation is accompanied by an [`Hlc`] timestamp that is stored
/// in a companion `hlc_timestamp` key inside the Automerge document.
#[derive(Debug)]
pub struct NoteCrdt {
doc: CrdtDoc,
}
impl NoteCrdt {
// ── Construction ─────────────────────────────────────────────────────
/// Creates a brand-new note CRDT document with required initial fields.
pub fn new(
id: &str,
namespace_id: &str,
title: &str,
content_markdown: &str,
hlc: &mut Hlc,
) -> Result<Self> {
let mut doc = CrdtDoc::new();
// Immutable metadata.
doc.put_str(keys::ENTITY_ID, id)?;
doc.put_str(keys::ENTITY_TYPE, "note")?;
// Mutable fields — initial values.
doc.put_str(keys::NAMESPACE_ID, namespace_id)?;
doc.put_str(keys::TITLE, title)?;
doc.put_str(keys::CONTENT_MD, content_markdown)?;
// Record the HLC.
let ts = hlc.tick();
doc.put_str(keys::HLC_TIMESTAMP, &ts.to_string())?;
Ok(Self { doc })
}
/// Loads a note CRDT document from previously saved bytes.
pub fn from_bytes(bytes: &[u8]) -> Result<Self> {
let doc = CrdtDoc::load(bytes)?;
Ok(Self { doc })
}
// ── Serialization ───────────────────────────────────────────────────
/// Serializes the document to bytes for storage in `crdt_docs.doc_bytes`.
pub fn save(&mut self) -> Vec<u8> {
self.doc.save()
}
/// Forks the document for independent editing (e.g., simulating a peer).
pub fn fork(&mut self) -> Self {
Self {
doc: self.doc.fork(),
}
}
/// Merges another note CRDT document into this one.
pub fn merge(&mut self, other: &mut NoteCrdt) -> Result<()> {
self.doc.merge(&mut other.doc)
}
/// Returns a shared reference to the underlying [`CrdtDoc`].
#[inline]
pub fn doc(&self) -> &CrdtDoc {
&self.doc
}
// ── Field getters ───────────────────────────────────────────────────
/// Returns the entity ID (UUIDv4) stored when the document was created.
pub fn entity_id(&self) -> Result<Option<String>> {
self.doc.get_str(keys::ENTITY_ID)
}
pub fn namespace_id(&self) -> Result<Option<String>> {
self.doc.get_str(keys::NAMESPACE_ID)
}
pub fn project_id(&self) -> Result<Option<String>> {
self.doc.get_str(keys::PROJECT_ID)
}
pub fn title(&self) -> Result<Option<String>> {
self.doc.get_str(keys::TITLE)
}
pub fn content_markdown(&self) -> Result<Option<String>> {
self.doc.get_str(keys::CONTENT_MD)
}
pub fn hlc_timestamp(&self) -> Result<Option<String>> {
self.doc.get_str(keys::HLC_TIMESTAMP)
}
// ── Field setters (LWW mutations) ───────────────────────────────────
/// Sets the note title (LWW register).
pub fn set_title(&mut self, title: &str, hlc: &mut Hlc) -> Result<()> {
self.doc.put_str(keys::TITLE, title)?;
self.stamp(hlc)
}
/// Sets the markdown content body (LWW register).
pub fn set_content_markdown(
&mut self,
content: &str,
hlc: &mut Hlc,
) -> Result<()> {
self.doc.put_str(keys::CONTENT_MD, content)?;
self.stamp(hlc)
}
/// Moves the note to a different project.
pub fn set_project_id(&mut self, project_id: &str, hlc: &mut Hlc) -> Result<()> {
self.doc.put_str(keys::PROJECT_ID, project_id)?;
self.stamp(hlc)
}
/// Removes the project association.
pub fn clear_project_id(&mut self, hlc: &mut Hlc) -> Result<()> {
self.doc.delete(keys::PROJECT_ID)?;
self.stamp(hlc)
}
/// Moves the note to a different namespace.
pub fn set_namespace_id(
&mut self,
namespace_id: &str,
hlc: &mut Hlc,
) -> Result<()> {
self.doc.put_str(keys::NAMESPACE_ID, namespace_id)?;
self.stamp(hlc)
}
// ── Private helpers ─────────────────────────────────────────────────
/// Advances the HLC and records the new timestamp in the document.
fn stamp(&mut self, hlc: &mut Hlc) -> Result<()> {
let ts = hlc.tick();
self.doc.put_str(keys::HLC_TIMESTAMP, &ts.to_string())
}
}
// ── Tests ────────────────────────────────────────────────────────────────────
#[cfg(test)]
mod tests {
use super::*;
const NODE: &str = "test-node";
fn make_hlc() -> Hlc {
Hlc::new(NODE)
}
#[test]
fn new_note_has_required_fields() {
let mut hlc = make_hlc();
let note =
NoteCrdt::new("n-001", "ns-1", "My Note", "# Hello World", &mut hlc)
.unwrap();
assert_eq!(note.entity_id().unwrap(), Some("n-001".into()));
assert_eq!(note.title().unwrap(), Some("My Note".into()));
assert_eq!(
note.content_markdown().unwrap(),
Some("# Hello World".into())
);
assert_eq!(note.namespace_id().unwrap(), Some("ns-1".into()));
assert!(note.hlc_timestamp().unwrap().is_some());
}
#[test]
fn set_and_get_all_fields() {
let mut hlc = make_hlc();
let mut note =
NoteCrdt::new("n-002", "ns-1", "Title", "Body", &mut hlc).unwrap();
note.set_title("New Title", &mut hlc).unwrap();
note.set_content_markdown("## Updated\nBody text", &mut hlc)
.unwrap();
note.set_project_id("p-001", &mut hlc).unwrap();
note.set_namespace_id("ns-2", &mut hlc).unwrap();
assert_eq!(note.title().unwrap(), Some("New Title".into()));
assert_eq!(
note.content_markdown().unwrap(),
Some("## Updated\nBody text".into())
);
assert_eq!(note.project_id().unwrap(), Some("p-001".into()));
assert_eq!(note.namespace_id().unwrap(), Some("ns-2".into()));
}
#[test]
fn clear_optional_fields() {
let mut hlc = make_hlc();
let mut note =
NoteCrdt::new("n-003", "ns-1", "Title", "Body", &mut hlc).unwrap();
note.set_project_id("p-1", &mut hlc).unwrap();
note.clear_project_id(&mut hlc).unwrap();
assert_eq!(note.project_id().unwrap(), None);
}
#[test]
fn save_load_round_trip() {
let mut hlc = make_hlc();
let mut note =
NoteCrdt::new("n-004", "ns-1", "Persist", "Content", &mut hlc).unwrap();
note.set_project_id("p-1", &mut hlc).unwrap();
let bytes = note.save();
let loaded = NoteCrdt::from_bytes(&bytes).unwrap();
assert_eq!(loaded.entity_id().unwrap(), Some("n-004".into()));
assert_eq!(loaded.title().unwrap(), Some("Persist".into()));
assert_eq!(loaded.content_markdown().unwrap(), Some("Content".into()));
assert_eq!(loaded.project_id().unwrap(), Some("p-1".into()));
}
#[test]
fn fork_merge_concurrent_content_edits() {
let mut hlc_a = Hlc::new("peer-a");
let mut hlc_b = Hlc::new("peer-b");
let mut note =
NoteCrdt::new("n-005", "ns-1", "Note", "Original", &mut hlc_a).unwrap();
let mut fork = note.fork();
note.set_content_markdown("Peer A content", &mut hlc_a)
.unwrap();
fork.set_content_markdown("Peer B content", &mut hlc_b)
.unwrap();
note.merge(&mut fork).unwrap();
let content = note.content_markdown().unwrap();
assert!(content.is_some(), "content must resolve after merge");
let c = content.unwrap();
assert!(
c == "Peer A content" || c == "Peer B content",
"winner must be one of the concurrent writes, got: {c}"
);
}
#[test]
fn hlc_timestamp_advances_on_mutations() {
let mut hlc = make_hlc();
let mut note =
NoteCrdt::new("n-006", "ns-1", "Note", "Body", &mut hlc).unwrap();
let ts1 = note.hlc_timestamp().unwrap().unwrap();
note.set_title("Changed", &mut hlc).unwrap();
let ts2 = note.hlc_timestamp().unwrap().unwrap();
assert!(ts2 > ts1, "HLC must advance: '{ts2}' > '{ts1}'");
}
}

View File

@ -0,0 +1,430 @@
//! Task CRDT register — typed LWW mutations for task entity fields.
//!
//! [`TaskCrdt`] wraps a [`CrdtDoc`] and exposes strongly typed accessors for
//! every mutable task field defined in `docs/API_CONTRACTS/schema.sql`. Each
//! mutation records the accompanying [`Hlc`] timestamp so that conflict
//! resolution is deterministic across peers.
//!
//! ## Field Mapping
//!
//! | SQL Column | CRDT Key | Type |
//! |-------------------------|-----------------------|---------------|
//! | `title` | `title` | String (LWW) |
//! | `description_markdown` | `description_markdown`| String (LWW) |
//! | `status` | `status` | String (LWW) |
//! | `priority_score` | `priority_score` | f64 (LWW) |
//! | `eisenhower_quadrant` | `eisenhower_quadrant` | i64 (LWW) |
//! | `due_date` | `due_date` | u64 (LWW) |
//! | `estimated_duration` | `estimated_duration` | i64 (LWW) |
//! | `is_archived` | `is_archived` | bool (LWW) |
//! | `completed_at` | `completed_at` | u64 (LWW) |
//! | `namespace_id` | `namespace_id` | String (LWW) |
//! | `project_id` | `project_id` | String (LWW) |
use super::doc::CrdtDoc;
use super::hlc::Hlc;
use crate::error::Result;
/// CRDT key constants matching the schema column names.
mod keys {
pub const ENTITY_ID: &str = "entity_id";
pub const ENTITY_TYPE: &str = "entity_type";
pub const NAMESPACE_ID: &str = "namespace_id";
pub const PROJECT_ID: &str = "project_id";
pub const TITLE: &str = "title";
pub const DESCRIPTION_MD: &str = "description_markdown";
pub const STATUS: &str = "status";
pub const PRIORITY_SCORE: &str = "priority_score";
pub const EISENHOWER_Q: &str = "eisenhower_quadrant";
pub const DUE_DATE: &str = "due_date";
pub const ESTIMATED_DUR: &str = "estimated_duration";
pub const IS_ARCHIVED: &str = "is_archived";
pub const COMPLETED_AT: &str = "completed_at";
pub const HLC_TIMESTAMP: &str = "hlc_timestamp";
}
/// Typed CRDT wrapper for a **task** entity.
///
/// Every field mutation is accompanied by an [`Hlc`] timestamp that is stored
/// in a companion `hlc_timestamp` key inside the Automerge document.
#[derive(Debug)]
pub struct TaskCrdt {
doc: CrdtDoc,
}
impl TaskCrdt {
// ── Construction ─────────────────────────────────────────────────────
/// Creates a brand-new task CRDT document with required initial fields.
pub fn new(
id: &str,
namespace_id: &str,
title: &str,
hlc: &mut Hlc,
) -> Result<Self> {
let mut doc = CrdtDoc::new();
// Immutable metadata.
doc.put_str(keys::ENTITY_ID, id)?;
doc.put_str(keys::ENTITY_TYPE, "task")?;
// Mutable fields — initial values.
doc.put_str(keys::NAMESPACE_ID, namespace_id)?;
doc.put_str(keys::TITLE, title)?;
doc.put_str(keys::STATUS, "backlog")?;
doc.put_f64(keys::PRIORITY_SCORE, 0.0)?;
doc.put_bool(keys::IS_ARCHIVED, false)?;
// Record the HLC.
let ts = hlc.tick();
doc.put_str(keys::HLC_TIMESTAMP, &ts.to_string())?;
Ok(Self { doc })
}
/// Loads a task CRDT document from previously saved bytes.
pub fn from_bytes(bytes: &[u8]) -> Result<Self> {
let doc = CrdtDoc::load(bytes)?;
Ok(Self { doc })
}
// ── Serialization ───────────────────────────────────────────────────
/// Serializes the document to bytes for storage in `crdt_docs.doc_bytes`.
pub fn save(&mut self) -> Vec<u8> {
self.doc.save()
}
/// Forks the document for independent editing (e.g., simulating a peer).
pub fn fork(&mut self) -> Self {
Self {
doc: self.doc.fork(),
}
}
/// Merges another task CRDT document into this one.
pub fn merge(&mut self, other: &mut TaskCrdt) -> Result<()> {
self.doc.merge(&mut other.doc)
}
/// Returns a shared reference to the underlying [`CrdtDoc`].
#[inline]
pub fn doc(&self) -> &CrdtDoc {
&self.doc
}
// ── Field getters ───────────────────────────────────────────────────
/// Returns the entity ID (UUIDv4) stored when the document was created.
pub fn entity_id(&self) -> Result<Option<String>> {
self.doc.get_str(keys::ENTITY_ID)
}
pub fn namespace_id(&self) -> Result<Option<String>> {
self.doc.get_str(keys::NAMESPACE_ID)
}
pub fn project_id(&self) -> Result<Option<String>> {
self.doc.get_str(keys::PROJECT_ID)
}
pub fn title(&self) -> Result<Option<String>> {
self.doc.get_str(keys::TITLE)
}
pub fn description_markdown(&self) -> Result<Option<String>> {
self.doc.get_str(keys::DESCRIPTION_MD)
}
pub fn status(&self) -> Result<Option<String>> {
self.doc.get_str(keys::STATUS)
}
pub fn priority_score(&self) -> Result<Option<f64>> {
self.doc.get_f64(keys::PRIORITY_SCORE)
}
pub fn eisenhower_quadrant(&self) -> Result<Option<i64>> {
self.doc.get_i64(keys::EISENHOWER_Q)
}
pub fn due_date(&self) -> Result<Option<u64>> {
self.doc.get_u64(keys::DUE_DATE)
}
pub fn estimated_duration(&self) -> Result<Option<i64>> {
self.doc.get_i64(keys::ESTIMATED_DUR)
}
pub fn is_archived(&self) -> Result<Option<bool>> {
self.doc.get_bool(keys::IS_ARCHIVED)
}
pub fn completed_at(&self) -> Result<Option<u64>> {
self.doc.get_u64(keys::COMPLETED_AT)
}
pub fn hlc_timestamp(&self) -> Result<Option<String>> {
self.doc.get_str(keys::HLC_TIMESTAMP)
}
// ── Field setters (LWW mutations) ───────────────────────────────────
/// Sets the task title (LWW register).
pub fn set_title(&mut self, title: &str, hlc: &mut Hlc) -> Result<()> {
self.doc.put_str(keys::TITLE, title)?;
self.stamp(hlc)
}
/// Sets the markdown description (LWW register).
pub fn set_description_markdown(
&mut self,
desc: &str,
hlc: &mut Hlc,
) -> Result<()> {
self.doc.put_str(keys::DESCRIPTION_MD, desc)?;
self.stamp(hlc)
}
/// Clears the description.
pub fn clear_description(&mut self, hlc: &mut Hlc) -> Result<()> {
self.doc.delete(keys::DESCRIPTION_MD)?;
self.stamp(hlc)
}
/// Sets the task status (`backlog`, `in_progress`, `review`, `completed`).
pub fn set_status(&mut self, status: &str, hlc: &mut Hlc) -> Result<()> {
self.doc.put_str(keys::STATUS, status)?;
self.stamp(hlc)
}
/// Sets the computed priority score.
pub fn set_priority_score(&mut self, score: f64, hlc: &mut Hlc) -> Result<()> {
self.doc.put_f64(keys::PRIORITY_SCORE, score)?;
self.stamp(hlc)
}
/// Sets the Eisenhower quadrant (1–4).
pub fn set_eisenhower_quadrant(
&mut self,
quadrant: i64,
hlc: &mut Hlc,
) -> Result<()> {
self.doc.put_i64(keys::EISENHOWER_Q, quadrant)?;
self.stamp(hlc)
}
/// Clears the Eisenhower quadrant.
pub fn clear_eisenhower_quadrant(&mut self, hlc: &mut Hlc) -> Result<()> {
self.doc.delete(keys::EISENHOWER_Q)?;
self.stamp(hlc)
}
/// Sets the due date (Unix epoch milliseconds).
pub fn set_due_date(&mut self, due_ms: u64, hlc: &mut Hlc) -> Result<()> {
self.doc.put_u64(keys::DUE_DATE, due_ms)?;
self.stamp(hlc)
}
/// Clears the due date.
pub fn clear_due_date(&mut self, hlc: &mut Hlc) -> Result<()> {
self.doc.delete(keys::DUE_DATE)?;
self.stamp(hlc)
}
/// Sets the estimated duration in minutes.
pub fn set_estimated_duration(
&mut self,
minutes: i64,
hlc: &mut Hlc,
) -> Result<()> {
self.doc.put_i64(keys::ESTIMATED_DUR, minutes)?;
self.stamp(hlc)
}
/// Sets the archived flag.
pub fn set_is_archived(&mut self, archived: bool, hlc: &mut Hlc) -> Result<()> {
self.doc.put_bool(keys::IS_ARCHIVED, archived)?;
self.stamp(hlc)
}
/// Sets the completion timestamp (Unix epoch milliseconds).
pub fn set_completed_at(&mut self, ts_ms: u64, hlc: &mut Hlc) -> Result<()> {
self.doc.put_u64(keys::COMPLETED_AT, ts_ms)?;
self.stamp(hlc)
}
/// Clears the completion timestamp.
pub fn clear_completed_at(&mut self, hlc: &mut Hlc) -> Result<()> {
self.doc.delete(keys::COMPLETED_AT)?;
self.stamp(hlc)
}
/// Moves the task to a different project.
pub fn set_project_id(&mut self, project_id: &str, hlc: &mut Hlc) -> Result<()> {
self.doc.put_str(keys::PROJECT_ID, project_id)?;
self.stamp(hlc)
}
/// Removes the project association.
pub fn clear_project_id(&mut self, hlc: &mut Hlc) -> Result<()> {
self.doc.delete(keys::PROJECT_ID)?;
self.stamp(hlc)
}
/// Moves the task to a different namespace.
pub fn set_namespace_id(
&mut self,
namespace_id: &str,
hlc: &mut Hlc,
) -> Result<()> {
self.doc.put_str(keys::NAMESPACE_ID, namespace_id)?;
self.stamp(hlc)
}
// ── Private helpers ─────────────────────────────────────────────────
/// Advances the HLC and records the new timestamp in the document.
fn stamp(&mut self, hlc: &mut Hlc) -> Result<()> {
let ts = hlc.tick();
self.doc.put_str(keys::HLC_TIMESTAMP, &ts.to_string())
}
}
// ── Tests ────────────────────────────────────────────────────────────────────
#[cfg(test)]
mod tests {
use super::*;
const NODE: &str = "test-node";
fn make_hlc() -> Hlc {
Hlc::new(NODE)
}
#[test]
fn new_task_has_default_fields() {
let mut hlc = make_hlc();
let task = TaskCrdt::new("t-001", "ns-1", "My Task", &mut hlc).unwrap();
assert_eq!(task.entity_id().unwrap(), Some("t-001".into()));
assert_eq!(task.title().unwrap(), Some("My Task".into()));
assert_eq!(task.status().unwrap(), Some("backlog".into()));
assert_eq!(task.namespace_id().unwrap(), Some("ns-1".into()));
assert!((task.priority_score().unwrap().unwrap() - 0.0).abs() < f64::EPSILON);
assert_eq!(task.is_archived().unwrap(), Some(false));
assert!(task.hlc_timestamp().unwrap().is_some());
}
#[test]
fn set_and_get_all_fields() {
let mut hlc = make_hlc();
let mut task = TaskCrdt::new("t-002", "ns-1", "Initial", &mut hlc).unwrap();
task.set_title("Updated Title", &mut hlc).unwrap();
task.set_description_markdown("# Heading", &mut hlc).unwrap();
task.set_status("in_progress", &mut hlc).unwrap();
task.set_priority_score(0.75, &mut hlc).unwrap();
task.set_eisenhower_quadrant(2, &mut hlc).unwrap();
task.set_due_date(1_700_000_000_000, &mut hlc).unwrap();
task.set_estimated_duration(60, &mut hlc).unwrap();
task.set_is_archived(true, &mut hlc).unwrap();
task.set_completed_at(1_700_100_000_000, &mut hlc).unwrap();
task.set_project_id("p-001", &mut hlc).unwrap();
assert_eq!(task.title().unwrap(), Some("Updated Title".into()));
assert_eq!(task.description_markdown().unwrap(), Some("# Heading".into()));
assert_eq!(task.status().unwrap(), Some("in_progress".into()));
assert!((task.priority_score().unwrap().unwrap() - 0.75).abs() < f64::EPSILON);
assert_eq!(task.eisenhower_quadrant().unwrap(), Some(2));
assert_eq!(task.due_date().unwrap(), Some(1_700_000_000_000));
assert_eq!(task.estimated_duration().unwrap(), Some(60));
assert_eq!(task.is_archived().unwrap(), Some(true));
assert_eq!(task.completed_at().unwrap(), Some(1_700_100_000_000));
assert_eq!(task.project_id().unwrap(), Some("p-001".into()));
}
#[test]
fn clear_optional_fields() {
let mut hlc = make_hlc();
let mut task = TaskCrdt::new("t-003", "ns-1", "Task", &mut hlc).unwrap();
task.set_description_markdown("desc", &mut hlc).unwrap();
task.clear_description(&mut hlc).unwrap();
assert_eq!(task.description_markdown().unwrap(), None);
task.set_eisenhower_quadrant(3, &mut hlc).unwrap();
task.clear_eisenhower_quadrant(&mut hlc).unwrap();
assert_eq!(task.eisenhower_quadrant().unwrap(), None);
task.set_due_date(123, &mut hlc).unwrap();
task.clear_due_date(&mut hlc).unwrap();
assert_eq!(task.due_date().unwrap(), None);
task.set_completed_at(456, &mut hlc).unwrap();
task.clear_completed_at(&mut hlc).unwrap();
assert_eq!(task.completed_at().unwrap(), None);
task.set_project_id("p-1", &mut hlc).unwrap();
task.clear_project_id(&mut hlc).unwrap();
assert_eq!(task.project_id().unwrap(), None);
}
#[test]
fn save_load_round_trip() {
let mut hlc = make_hlc();
let mut task = TaskCrdt::new("t-004", "ns-1", "Persist", &mut hlc).unwrap();
task.set_status("completed", &mut hlc).unwrap();
task.set_priority_score(0.9, &mut hlc).unwrap();
let bytes = task.save();
let loaded = TaskCrdt::from_bytes(&bytes).unwrap();
assert_eq!(loaded.entity_id().unwrap(), Some("t-004".into()));
assert_eq!(loaded.title().unwrap(), Some("Persist".into()));
assert_eq!(loaded.status().unwrap(), Some("completed".into()));
assert!((loaded.priority_score().unwrap().unwrap() - 0.9).abs() < f64::EPSILON);
}
#[test]
fn fork_merge_concurrent_title_edits() {
let mut hlc_a = Hlc::new("peer-a");
let mut hlc_b = Hlc::new("peer-b");
let mut task = TaskCrdt::new("t-005", "ns-1", "Original", &mut hlc_a).unwrap();
// Fork simulates a second peer.
let mut fork = task.fork();
// Concurrent edits.
task.set_title("Peer A title", &mut hlc_a).unwrap();
fork.set_title("Peer B title", &mut hlc_b).unwrap();
// Merge: Automerge picks a deterministic winner.
task.merge(&mut fork).unwrap();
let title = task.title().unwrap();
assert!(
title.is_some(),
"title must resolve to a value after merge"
);
let t = title.unwrap();
assert!(
t == "Peer A title" || t == "Peer B title",
"winner must be one of the two concurrent writes, got: {t}"
);
}
#[test]
fn hlc_timestamp_advances_on_mutations() {
let mut hlc = make_hlc();
let mut task = TaskCrdt::new("t-006", "ns-1", "Task", &mut hlc).unwrap();
let ts1 = task.hlc_timestamp().unwrap().unwrap();
task.set_title("Changed", &mut hlc).unwrap();
let ts2 = task.hlc_timestamp().unwrap().unwrap();
assert!(ts2 > ts1, "HLC must advance: '{ts2}' > '{ts1}'");
}
}

View File

@ -38,6 +38,7 @@ const EXPECTED_TABLES: &[&str] = &[
"notes",
"task_note_links",
"ingestion_logs",
"crdt_docs",
];
// ── Database struct ──────────────────────────────────────────────────────────
@ -103,6 +104,55 @@ impl Database {
&mut self.conn
}
// ── CRDT document persistence ────────────────────────────────────────
/// Saves (upserts) an Automerge CRDT document's serialized bytes into the
/// `crdt_docs` table.
///
/// If a row for `entity_id` already exists it is replaced.
pub fn save_crdt_doc(
&self,
entity_id: &str,
entity_type: &str,
doc_bytes: &[u8],
updated_at: u64,
) -> Result<()> {
self.conn.execute(
"INSERT OR REPLACE INTO crdt_docs (entity_id, entity_type, doc_bytes, updated_at)
VALUES (?1, ?2, ?3, ?4);",
rusqlite::params![entity_id, entity_type, doc_bytes, updated_at as i64],
)?;
Ok(())
}
/// Loads the serialized Automerge CRDT document bytes for a given entity.
///
/// Returns `None` if no CRDT document has been stored for `entity_id`.
pub fn load_crdt_doc(&self, entity_id: &str) -> Result<Option<Vec<u8>>> {
let mut stmt = self
.conn
.prepare("SELECT doc_bytes FROM crdt_docs WHERE entity_id = ?1;")?;
let result = stmt.query_row([entity_id], |row| row.get::<_, Vec<u8>>(0));
match result {
Ok(bytes) => Ok(Some(bytes)),
Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None),
Err(e) => Err(CoreError::Database(e)),
}
}
/// Deletes the CRDT document for a given entity.
///
/// Returns `true` if a row was deleted, `false` if none existed.
pub fn delete_crdt_doc(&self, entity_id: &str) -> Result<bool> {
let affected = self.conn.execute(
"DELETE FROM crdt_docs WHERE entity_id = ?1;",
[entity_id],
)?;
Ok(affected > 0)
}
// ── Private helpers ──────────────────────────────────────────────────
/// Applies encryption key, PRAGMAs, schema DDL, and validates table presence.
@ -210,6 +260,7 @@ mod tests {
"idx_tasks_eisenhower",
"idx_tasks_due_date",
"idx_projects_parent",
"idx_crdt_docs_type",
];
for &idx in &expected_indexes {
@ -415,4 +466,49 @@ mod tests {
.unwrap();
assert_eq!(count, 0, "cascade delete must remove child tasks");
}
// ── CRDT document persistence ────────────────────────────────────────
#[test]
fn save_and_load_crdt_doc() {
let db = Database::open_in_memory(&TEST_KEY).unwrap();
let blob = b"fake-automerge-bytes";
db.save_crdt_doc("t-001", "task", blob, 1000).unwrap();
let loaded = db.load_crdt_doc("t-001").unwrap();
assert_eq!(loaded, Some(blob.to_vec()));
}
#[test]
fn load_missing_crdt_doc_returns_none() {
let db = Database::open_in_memory(&TEST_KEY).unwrap();
assert_eq!(db.load_crdt_doc("nonexistent").unwrap(), None);
}
#[test]
fn save_crdt_doc_upserts() {
let db = Database::open_in_memory(&TEST_KEY).unwrap();
db.save_crdt_doc("t-001", "task", b"v1", 1000).unwrap();
db.save_crdt_doc("t-001", "task", b"v2", 2000).unwrap();
let loaded = db.load_crdt_doc("t-001").unwrap().unwrap();
assert_eq!(loaded, b"v2");
}
#[test]
fn delete_crdt_doc_removes_row() {
let db = Database::open_in_memory(&TEST_KEY).unwrap();
db.save_crdt_doc("t-001", "task", b"data", 1000).unwrap();
assert!(db.delete_crdt_doc("t-001").unwrap());
assert_eq!(db.load_crdt_doc("t-001").unwrap(), None);
}
#[test]
fn delete_missing_crdt_doc_returns_false() {
let db = Database::open_in_memory(&TEST_KEY).unwrap();
assert!(!db.delete_crdt_doc("nonexistent").unwrap());
}
}

View File

@ -20,6 +20,14 @@ pub enum CoreError {
/// Schema migration or initialization failure.
#[error("schema initialization failed: {0}")]
SchemaInit(String),
/// An error originating from the Automerge CRDT layer.
#[error("CRDT error: {0}")]
Crdt(String),
/// Failed to parse a Hybrid Logical Clock (HLC) timestamp string.
#[error("HLC parse error: {0}")]
HlcParse(String),
}
/// Convenience alias used throughout the crate.

View File

@ -9,12 +9,15 @@
//! `chronogrid_crypto::kdf::derive_master_key`). The canonical schema from
//! `docs/API_CONTRACTS/schema.sql` is applied automatically on first open.
//!
//! * **Automerge CRDT** *(planned – Task 1.4)* — conflict-free replicated
//! data-type registers for tasks and notes, enabling offline-first P2P sync.
//! * **Automerge CRDT** — [`crdt`] provides conflict-free replicated data-type
//! registers for tasks and notes, enabling offline-first P2P sync via
//! LWW-Element-Set registers with Hybrid Logical Clock ordering.
pub mod crdt;
pub mod db;
pub mod error;
// Re-export key public types at crate root for ergonomic imports.
pub use crdt::{CrdtDoc, Hlc, NoteCrdt, TaskCrdt};
pub use db::Database;
pub use error::{CoreError, Result};

View File

@ -83,7 +83,16 @@ CREATE TABLE IF NOT EXISTS ingestion_logs (
FOREIGN KEY(processed_task_id) REFERENCES tasks(id) ON DELETE SET NULL
);
-- CRDT Document Storage (Automerge binary state)
CREATE TABLE IF NOT EXISTS crdt_docs (
entity_id TEXT PRIMARY KEY, -- References task.id or note.id
entity_type TEXT NOT NULL, -- 'task' or 'note'
doc_bytes BLOB NOT NULL, -- Serialized Automerge document
updated_at INTEGER NOT NULL -- Unix Epoch (ms)
);
CREATE INDEX IF NOT EXISTS idx_tasks_namespace ON tasks(namespace_id);
CREATE INDEX IF NOT EXISTS idx_tasks_eisenhower ON tasks(eisenhower_quadrant);
CREATE INDEX IF NOT EXISTS idx_tasks_due_date ON tasks(due_date);
CREATE INDEX IF NOT EXISTS idx_projects_parent ON projects(parent_id);
CREATE INDEX IF NOT EXISTS idx_crdt_docs_type ON crdt_docs(entity_type);

View File

@ -0,0 +1,43 @@
# ADR-0004: chronogrid_core – Automerge CRDT Integration for Task & Note Registers
* **Status**: Accepted
* **Date**: 2026-08-20
* **Deciders**: Michael Kell
## Context & Problem Statement
Task 1.4 requires integrating the Automerge CRDT engine into `chronogrid_core` so that task and note entities can be modified concurrently by different peers and merged deterministically without centralized coordination. The architecture spec (`03-crdt-sync-protocol.md`) mandates LWW-Element-Set registers for primitive fields and Hybrid Logical Clocks (HLC) for causal ordering.
## Options Considered
1. **Manual LWW register implementation** — track per-field HLC timestamps in a custom data structure and resolve conflicts manually.
2. **Automerge integration with HLC companion stamps** — use Automerge's built-in CRDT merge semantics for structural conflict resolution, augmented with HLC timestamps stored inside each document for causal ordering context.
3. **Yrs (Yjs Rust port)** — alternative CRDT library focused on text collaboration.
## Decision Outcome
Chosen Option: **Option 2 — Automerge + HLC** because:
- Automerge provides battle-tested CRDT merge, fork, save/load, and delta-sync primitives out of the box.
- Storing HLC timestamps as companion fields inside the Automerge document aligns with the spec's requirement for HLC-based LWW conflict resolution while leveraging Automerge's deterministic merge for structural integrity.
- The `automerge` crate (v0.5.12) has a stable API with `AutoCommit`, `ReadDoc`, and `Transactable` traits that map cleanly to our per-entity document model.
- Yrs is optimized for text sequences, not key-value LWW registers.
## Implementation Details
- **`automerge = "0.5"`** added to `chronogrid_core/Cargo.toml` (resolved to v0.5.12).
- **`uuid = "1"` with `v4` feature** added for entity ID generation.
- **Schema extension**: `crdt_docs` table added to `schema.sql` with columns `entity_id TEXT PK`, `entity_type TEXT`, `doc_bytes BLOB`, `updated_at INTEGER`, plus `idx_crdt_docs_type` index.
- **Module structure**: `crdt/` sub-module with `hlc.rs`, `doc.rs`, `task.rs`, `note.rs`.
- **HLC**: `Hlc` struct with `tick()`, `update()`, `Display`/`FromStr` (format: `{wall_ms:016}-{counter:08x}-{node_id}`), and full `Ord` implementation ensuring lexicographic string order matches logical order.
- **CrdtDoc**: Generic wrapper over `AutoCommit` providing typed `put_*/get_*` accessors for String, f64, i64, u64, bool scalars at the document root.
- **TaskCrdt / NoteCrdt**: Entity-specific wrappers with field-level setters that automatically advance the HLC and record it in the document.
- **Database persistence**: `save_crdt_doc()`, `load_crdt_doc()`, `delete_crdt_doc()` methods on `Database` for upsert/load/delete of serialized Automerge bytes.
- **45 unit tests** covering: HLC ordering, tick/update/parse, all scalar types, save/load round-trips, fork/merge conflict resolution, CRDT doc DB persistence, and schema validation.
## Consequences & Trade-offs
* **Positive**: Full offline-first CRDT capability; entities can be forked, edited independently, and merged without data loss. HLC provides deterministic causal ordering for the SQL layer. Zero external network dependencies (local-first mandate satisfied).
* **Positive**: Automerge's compact binary format minimizes storage overhead for CRDT doc blobs.
* **Negative**: Automerge 0.5.x adds ~40 transitive dependencies; binary size increases moderately.
* **Negative**: Each entity requires both a SQL row and a CRDT doc blob, introducing a dual-write pattern that must be kept in sync (future work: transactional wrappers).

View File

@ -5,5 +5,5 @@
- [x] **Task 1.1**: Initialize Rust Cargo Workspace in crates/ and configure root Cargo.toml.
- [x] **Task 1.2**: Implement chronogrid_crypto crate with Argon2id master key derivation and OS keychain bindings.
- [x] **Task 1.3**: Implement chronogrid_core database initializers using SQLCipher 4.x matching docs/API_CONTRACTS/schema.sql.
- [ ] **Task 1.4**: Integrate Automerge Rust crate into chronogrid_core for task and note CRDT register mutations.
- [x] **Task 1.4**: Integrate Automerge Rust crate into chronogrid_core for task and note CRDT register mutations.
- [ ] **Task 1.5**: Generate lutter_rust_bridge FFI bindings bridging Rust storage methods to Dart.