From 24a0a3d1537802eeca7169143260e4b6f9df5d79 Mon Sep 17 00:00:00 2001 From: Eric Wendland Date: Sun, 5 Jul 2026 22:51:47 +0200 Subject: [PATCH] docs: define sync conflict semantics --- README.md | 4 ++ crates/geth-node/src/lib.rs | 82 +++++++++++++++++++--- docs/architecture.md | 3 + docs/conflict-semantics.md | 101 +++++++++++++++++++++++++++ docs/production-readiness-roadmap.md | 10 +-- 5 files changed, 186 insertions(+), 14 deletions(-) create mode 100644 docs/conflict-semantics.md diff --git a/README.md b/README.md index 46bbaaf..f484ee9 100644 --- a/README.md +++ b/README.md @@ -445,6 +445,8 @@ validity, and representative peer grants when local metadata is available. Use See `docs/automation-examples.md` for shell, Python JSON, and user-service automation examples. +See `docs/conflict-semantics.md` for the current per-resource conflict behavior +used by CAS/file roots, KV, documents, DB sync, and signed metadata logs. ## Owner And Node Management @@ -666,6 +668,8 @@ stability rules for CLI commands, `--json` output, the local control JSONL protocol, Iroh peer wire protocols, SQLite metadata, and signed operation logs. Command-family stability levels are tracked in [`docs/command-stability.md`](docs/command-stability.md). +Per-resource conflict behavior is tracked in +[`docs/conflict-semantics.md`](docs/conflict-semantics.md). GitHub Actions workflows live under `.github/workflows/`: diff --git a/crates/geth-node/src/lib.rs b/crates/geth-node/src/lib.rs index 5c1e482..fe0967b 100644 --- a/crates/geth-node/src/lib.rs +++ b/crates/geth-node/src/lib.rs @@ -1943,9 +1943,13 @@ async fn kv_sync_from_peer( for entry in entries { geth_kv::validate_kv_key(&entry.key) .map_err(|_| NodeError::InvalidKvKey(entry.key.clone()))?; - let should_import = store - .get_kv_entry(&kv.kv_id, &entry.key)? - .is_none_or(|local| entry.updated_at_ms >= local.updated_at_ms); + let should_import = should_import_kv_sync_entry( + store + .get_kv_entry(&kv.kv_id, &entry.key)? + .as_ref() + .map(|local| local.updated_at_ms), + entry.updated_at_ms, + ); if should_import { store.set_kv_entry(&StoredKvEntry { kv_id: kv.kv_id.clone(), @@ -3229,12 +3233,12 @@ async fn document_sync_from_peer( let mut updated = false; if let Some(state) = state { let local = ensure_local_document(&store, name)?; - if state.updated_at.0 >= local.updated_at_ms { - let state_json = if state.updated_at.0 > local.updated_at_ms { - state.state_json - } else { - geth_document::merge_automerge_states(&local.state_json, &state.state_json)? - }; + if let Some(state_json) = document_sync_state_json( + &local.state_json, + local.updated_at_ms, + &state.state_json, + state.updated_at.0, + )? { store.insert_document_resource(&StoredDocumentResource { document_id: local.document_id, resource_id: local.resource_id, @@ -3267,6 +3271,32 @@ async fn document_sync_from_peer( } } +fn should_import_kv_sync_entry( + local_updated_at_ms: Option, + remote_updated_at_ms: i64, +) -> bool { + local_updated_at_ms + .is_none_or(|local_updated_at_ms| remote_updated_at_ms >= local_updated_at_ms) +} + +fn document_sync_state_json( + local_state_json: &str, + local_updated_at_ms: i64, + remote_state_json: &str, + remote_updated_at_ms: i64, +) -> Result, NodeError> { + if remote_updated_at_ms < local_updated_at_ms { + return Ok(None); + } + if remote_updated_at_ms > local_updated_at_ms { + return Ok(Some(remote_state_json.to_owned())); + } + Ok(Some(geth_document::merge_automerge_states( + local_state_json, + remote_state_json, + )?)) +} + async fn sync_status_from_peer( node: &LocalNode, peer_node: &str, @@ -11392,6 +11422,40 @@ mod tests { std::env::var_os("GETH_TEST_SKIP_IROH").is_some() } + #[test] + fn kv_sync_import_rule_is_timestamp_last_writer_wins() { + assert!(should_import_kv_sync_entry(None, 10)); + assert!(!should_import_kv_sync_entry(Some(11), 10)); + assert!(should_import_kv_sync_entry(Some(10), 10)); + assert!(should_import_kv_sync_entry(Some(9), 10)); + } + + #[test] + fn document_sync_rule_merges_equal_timestamps_only() { + let older = + document_sync_state_json(r#"{"title":"local"}"#, 20, r#"{"title":"remote"}"#, 19) + .expect("older remote"); + assert_eq!(older, None); + + let newer = + document_sync_state_json(r#"{"title":"local"}"#, 20, r#"{"title":"remote"}"#, 21) + .expect("newer remote"); + assert_eq!(newer.as_deref(), Some(r#"{"title":"remote"}"#)); + + let local_state = + geth_document::create_automerge_state(r#"{"local":true}"#).expect("local envelope"); + let remote_state = + geth_document::create_automerge_state(r#"{"remote":true}"#).expect("remote envelope"); + let equal = document_sync_state_json(&local_state, 20, &remote_state, 20) + .expect("equal timestamp merge") + .expect("merged state"); + let merged: serde_json::Value = + serde_json::from_str(&geth_document::document_view_json(&equal).expect("view json")) + .expect("merged json"); + assert_eq!(merged["local"], true); + assert_eq!(merged["remote"], true); + } + #[derive(Debug, PartialEq, Eq)] enum RemoteGuardKind { Capability, diff --git a/docs/architecture.md b/docs/architecture.md index 789d97d..5005453 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -185,6 +185,9 @@ Resource kinds: ## Module Overview +The current per-resource sync and conflict behavior is a documented +automation-facing contract in [`conflict-semantics.md`](conflict-semantics.md). + `geth-cas` is implemented locally first using BLAKE3 hashes and filesystem blob storage. Local pin/unpin metadata is tracked in SQLite and surfaced in `cas list`. `cas cleanup` removes unpinned local blobs while retaining pinned diff --git a/docs/conflict-semantics.md b/docs/conflict-semantics.md new file mode 100644 index 0000000..4b3a720 --- /dev/null +++ b/docs/conflict-semantics.md @@ -0,0 +1,101 @@ +# Conflict Semantics + +This document describes the current pre-deployment conflict behavior for +resource sync. It is an automation contract for scripts built on `geth`; it is +not a claim that every resource has final multi-writer reconciliation. + +All remote sync paths first authenticate the peer-card endpoint binding and +check the resource capability for the requested stream. Discovery metadata, +peer-card import, or LAN discovery never grants conflict-resolution rights by +itself. + +## CAS File Roots + +CAS blobs are immutable and addressed by BLAKE3 hash, so blob fetch has no merge +conflict. File-root sync and apply are the stateful parts: + +- `geth cas root sync ` imports authorized remote tree metadata and + tree bytes into a peer-qualified remote root. It does not write files. +- Repeated syncs retain the previous imported remote tree as the base. When a + same-named local root and the new remote root both changed, geth records + durable `concurrent-edit`, `delete-edit`, or `rename` conflicts. +- `geth cas root apply --to ` is conservative. Without a + registered local base it creates missing files/directories, does not delete + extra files, and does not overwrite differing local files. +- With a registered local root base, apply performs a three-way + base/local/remote check. It applies non-conflicting creates, updates, deletes, + and renames only where local state still matches the base. +- Ambiguous paths remain local conflict records until resolved through + `geth cas conflict resolve`. + +The current tests cover CAS tree diffing, safe three-way apply, durable +file-conflict records, and automatic sync conflict recording. + +## KV Stores + +Named KV stores are durable local SQLite indexes mirrored through native +Iroh Documents when available. + +- Manual `geth kv sync` and background live-sync require `kv.read` on + `resource:kv:`. +- Import is timestamp-based last-writer-wins per key. +- A remote value replaces the local value when the remote `updated_at_ms` is + greater than or equal to the local timestamp. +- A remote value older than the local value is ignored. +- There is no tombstone or multi-value conflict record for KV yet. + +The current tests cover authorized KV sync and the timestamp import rule. + +## Documents + +Document resources persist Automerge save bytes, while the CLI exposes a +validated JSON view. + +- Manual `geth document sync` and background live-sync require `document.read` + on `resource:document:`. +- A remote state older than the local document timestamp is ignored. +- A remote state newer than the local timestamp replaces the local Automerge + state and JSON view. +- Equal timestamps are merged with the current Automerge merge helper. +- Rich document conflict UI, semantic JSON merge policy, and per-field conflict + reporting are future work. + +The current tests cover Automerge merge behavior, authorized document sync, and +the sync timestamp decision rule. + +## DB Resources + +DB resources are a staged cr-sqlite path rather than a general SQLite merge +engine. + +- Manual `geth db sync` and background live-sync require `db.sync` on + `resource:db:`. +- The requester compares remote schema metadata with the local DB schema before + applying a batch. +- Compatible batches are inserted into the local `crsql_changes` table or view, + then the per-peer cursor advances. +- Incompatible schema metadata causes the batch to be skipped and the cursor is + not advanced for that batch. +- Loading/configuring the cr-sqlite extension for real application databases is + outside this bootstrap. + +The current tests cover deterministic `crsql_changes` extraction/application, +authorized DB sync, and schema-compatible batch application. Schema evolution +and rollback expectations are tracked in the upgrade-test roadmap item. + +## Signed Logs And Metadata Sync + +Keychain, auth, SSH certificate, and SSH revocation sync are append-only +metadata flows with provenance checks. + +- New keychain/auth entries must have valid signatures from currently trusted + admin keys. +- SSH certificate and revocation entries must carry valid agent-key signed + provenance over canonical payloads. +- If an incoming record has an already-known id but different content, the + import rejects that record instead of overwriting local metadata. +- Accepted keychain/auth op/signature groups are staged and committed through + store batch transactions. + +The current tests cover unsigned, invalidly signed, and conflicting signed-log +imports. diff --git a/docs/production-readiness-roadmap.md b/docs/production-readiness-roadmap.md index e872118..554e8c3 100644 --- a/docs/production-readiness-roadmap.md +++ b/docs/production-readiness-roadmap.md @@ -221,12 +221,12 @@ Goal: make convergence and failure behavior predictable enough for automation. - `[ ]` Tests cover partial stream failure. - `[ ]` Tests cover duplicate records and stale cursors. -- `[ ]` Define per-resource conflict semantics. +- `[x]` Define per-resource conflict semantics. Acceptance criteria: - - `[ ]` CAS/file-root conflict semantics are documented and tested. - - `[ ]` KV conflict semantics are documented and tested. - - `[ ]` Document merge semantics are documented and tested. - - `[ ]` DB change application limits are documented and tested. + - `[x]` CAS/file-root conflict semantics are documented and tested. + - `[x]` KV conflict semantics are documented and tested. + - `[x]` Document merge semantics are documented and tested. + - `[x]` DB change application limits are documented and tested. - `[ ]` Add live-sync retry and backoff policy. Acceptance criteria: