# ADR-0012: chronogrid_core — SyncEngine P2P Integration (Task 2.6) ## Status Accepted ## Date 2026-08-22 ## Context Task 2.6 required wiring the P2P sync layer from `chronogrid_net` into `chronogrid_core` so that CRDT delta packets received from remote peers are automatically merged into the local SQLCipher database, and local CRDT mutations are broadcast to connected peers. The integration must: - Remain optional (not break the existing `chronogrid_core` build without the `p2p` feature) - Bridge `NetEvent::DeltasReceived` → Automerge merge → SQL row update - Bridge local CRDT mutations → delta computation → `SwarmHandle::trigger_sync()` - Provide `merge_remote_deltas()` returning affected entity IDs for UI invalidation ## Decision ### Feature Gate `chronogrid_net` is added as an **optional dependency** in `chronogrid_core/Cargo.toml` under the `p2p` feature: ```toml [features] p2p = ["dep:chronogrid_net", "dep:tokio", "dep:tracing"] [dependencies] chronogrid_net = { path = "../chronogrid_net", optional = true } tokio = { version = "1", features = ["sync", "rt"], optional = true } tracing = { version = "0.1", optional = true } ``` This ensures that `cargo build --package chronogrid_core` (without `--features p2p`) compiles cleanly with no libp2p or OpenSSL dependencies, keeping the base crate lightweight for FFI and test builds. ### SyncEngine Architecture A new `core::sync` module is created with a single `SyncEngine` struct: ``` crates/chronogrid_core/src/sync/mod.rs ``` `SyncEngine` owns: - `Arc>` — shared reference to the encrypted SQLCipher database - `SwarmHandle` — cloneable handle to the running libp2p swarm - `tokio::sync::broadcast::Receiver` — event stream from the swarm The engine runs a background Tokio task (`tokio::spawn`) that loops on `event_rx.recv()`: - `NetEvent::DeltasReceived { from_peer, deltas }` → calls `merge_remote_deltas()` - `NetEvent::SyncComplete` / `NetEvent::SyncError` → logged via `tracing` ### Delta Merge Flow `merge_remote_deltas(deltas: Vec) -> Result>`: 1. For each `DeltaPacket`, extract `entity_id` and `entity_type` 2. Load existing `crdt_docs` row from database (or create new empty Automerge doc) 3. Merge the incoming Automerge changeset bytes via `automerge::AutoCommit::load_incremental()` 4. Persist the merged doc bytes back to `crdt_docs` 5. Update the corresponding SQL entity row (`tasks`, `notes`, `time_blocks`, etc.) with the merged scalar values 6. Return the list of affected `entity_id` strings for UI cache invalidation ### Local Mutation → Broadcast Flow `SyncEngine::notify_local_change(entity_id: &str)`: - Calls `swarm_handle.trigger_sync()` to initiate delta exchange with all connected peers - The `SwarmManager` computes the delta by comparing local `VectorClock` with each peer's last-known clock ### Error Handling - Merge errors are logged via `tracing::warn!` and do not crash the engine - Individual delta failures are isolated — one bad packet does not block others - `CoreError` gains a new `Sync(String)` variant for sync-specific errors ### Test Strategy Unit tests use `Database::open_in_memory()` and a mock `SwarmHandle` (created via `SwarmHandle::new_for_test()`) to avoid requiring a live libp2p swarm: - `test_merge_remote_deltas_task` — merges a task delta, verifies `crdt_docs` updated - `test_merge_remote_deltas_note` — merges a note delta, verifies `crdt_docs` updated - `test_merge_empty_deltas` — empty delta list returns empty affected IDs - `test_sync_engine_new` — engine constructs without panicking Tests are gated behind `#[cfg(all(test, feature = "p2p"))]`. ## Consequences ### Positive - `chronogrid_core` without `--features p2p` remains a lightweight, zero-network-dependency crate suitable for FFI and embedded use - The `SyncEngine` provides a clean boundary between network events and database mutations - `merge_remote_deltas()` returns affected entity IDs, enabling targeted UI cache invalidation in Phase 3 - Automerge's deterministic conflict resolution handles concurrent edits without application-level logic ### Negative / Trade-offs - The `p2p` feature adds significant compile-time overhead (libp2p + OpenSSL) on first build - `SwarmHandle::new_for_test()` requires `chronogrid_net` to expose a test-only constructor, adding a small surface area to the net crate's public API - The background Tokio task requires the caller to hold the `SyncEngine` alive for the duration of the session ## Alternatives Considered ### Alternative 1: Separate `chronogrid_sync` Crate A dedicated crate could own the bridge logic. Rejected because it adds workspace complexity without benefit — the sync engine is tightly coupled to `Database` internals and belongs in `chronogrid_core`. ### Alternative 2: Callback-Based Integration Instead of a background task, expose a `process_event(NetEvent)` method for the caller to drive. Rejected because it pushes event-loop management into the daemon/FFI layer, violating the single-responsibility principle. ### Alternative 3: Always-On Dependency Make `chronogrid_net` a non-optional dependency. Rejected because it would force OpenSSL compilation for all `chronogrid_core` consumers (FFI, tests, daemon), significantly increasing build times.