Skip to content

fix(storage)!: fail on unregistered root entity merge#1988

Merged
xilosada merged 14 commits intomasterfrom
fix/i5-unregistered-root-merge
Feb 14, 2026
Merged

fix(storage)!: fail on unregistered root entity merge#1988
xilosada merged 14 commits intomasterfrom
fix/i5-unregistered-root-merge

Conversation

@xilosada
Copy link
Member

@xilosada xilosada commented Feb 14, 2026

Summary

  • Replace silent LWW fallback with error in merge_root_state() to enforce Invariant I5 (No Silent Data Loss)
  • Error message provides actionable fix instructions: use #[app::state] macro or call register_crdt_merge::<YourState>()
  • Update documentation to reflect the new required behavior

Test plan

  • All 355 storage tests pass (they register merge functions before calling merge_root_state())
  • cargo fmt and cargo clippy pass on storage crate
  • CI passes

Note

Medium Risk
Behavior change turns previously “best-effort” root conflict resolution into a hard error for unregistered apps/tests, which can break sync flows until registration is added. Core merge/error plumbing and test setup are touched, but the change is localized and includes explicit error messaging.

Overview
Enforces I5 by removing silent LWW fallback for root merges. merge_root_state() now requires a registered merge function and returns MergeError::NoMergeFunctionRegistered when the merge registry is empty; callers (Interface::try_merge_data) propagate this as StorageError::MergeFailure instead of choosing the newer blob.

The merge registry API is adjusted to return a MergeRegistryResult (success / no functions / all failed), with root merge only allowing an LWW fallback when merge functions exist but none succeed (with a warning). Tests are updated to register merge functions up-front (adding simple Mergeable impls for test types plus a shared register_test_merge_functions() helper), and docs are rewritten to reflect the new “must register” behavior and the expected error message.

Written by Cursor Bugbot for commit 7a1b5bc. This will update automatically on new commits. Configure here.

Remove silent LWW fallback that violated I5 (No Silent Data Loss).
Now returns error with actionable fix instructions.
Copy link

@meroreviewer meroreviewer bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🤖 AI Code Reviewer

Reviewed by 3 agents | Quality score: 67% | Review time: 208.9s

📝 1 nitpicks. See inline comments.


🤖 Generated by AI Code Reviewer | Review ID: review-ffd1bf76

@cursor

This comment has been minimized.

cursor bot and others added 2 commits February 14, 2026 15:28
Address review feedback: introduce MergeError::NoMergeFunctionRegistered
variant for better downstream error handling instead of Box<dyn Error>.
Copy link

@meroreviewer meroreviewer bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🤖 AI Code Reviewer

Reviewed by 3 agents | Quality score: 86% | Review time: 286.1s

🟡 3 warnings, 💡 4 suggestions. See inline comments.


🤖 Generated by AI Code Reviewer | Review ID: review-b05a5d2c

Copy link

@meroreviewer meroreviewer bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🤖 AI Code Reviewer

Reviewed by 3 agents | Quality score: 75% | Review time: 322.0s

💡 3 suggestions, 📝 1 nitpicks. See inline comments.


🤖 Generated by AI Code Reviewer | Review ID: review-2c6cae13

@xilosada
Copy link
Member Author

Review Comments Status

✅ Addressed Comments

  1. I5 enforcement defeated by caller's LWW fallback (cursor[bot])

  2. Consider a typed error for better downstream handling (meroreviewer)

    • Addressed in commit 4eb17ec
    • Added MergeError::NoMergeFunctionRegistered variant
    • Changed function signature to Result<Vec, MergeError>

⏳ Pending Comments (for future consideration)

  1. AllFunctionsFailed LWW fallback lacks observability - Multiple comments suggest adding warn! logging when all registered merge functions fail and we fall back to LWW. Per Delivery Contract Rule, any drop should be observable.

  2. Lock poisoning incorrectly reported as NoFunctionsRegistered - If MERGE_REGISTRY.read() fails due to lock poisoning, returning NoFunctionsRegistered is semantically incorrect. Suggestion: add logging or abort like the write side does.

  3. ActionNotAllowed may not semantically fit merge failures - StorageError::ActionNotAllowed sounds like a permission error. Suggestion: consider a StorageError::MergeFailure variant.

These pending items are valid suggestions for observability improvements but are out of scope for this I5 enforcement PR.

@cursor

This comment has been minimized.

- Add warn! logging when AllFunctionsFailed falls back to LWW
  (Delivery Contract Rule: any drop MUST be observable)
- Handle lock poisoning with abort instead of silent NoFunctionsRegistered
  (consistent with write side, prevents undefined behavior)
- Add dedicated StorageError::MergeError variant
  (ActionNotAllowed semantically implies permission error)
@xilosada
Copy link
Member Author

All PR Review Feedback Addressed ✅

Pushed commit 5b8aa722 addressing all remaining feedback:

1. AllFunctionsFailed LWW fallback now observable

Added tracing::warn! when all registered merge functions fail:

tracing::warn!(
    target: "calimero_storage::merge",
    "All registered merge functions failed, falling back to LWW. \
     This may indicate type mismatch or corrupt data."
);

Per Delivery Contract Rule: any drop MUST be observable.

2. Lock poisoning handled correctly

Changed from returning NoFunctionsRegistered to aborting:

let registry = MERGE_REGISTRY.read().unwrap_or_else(|poisoned| {
    tracing::error!(
        target: "calimero_storage::merge",
        "MERGE_REGISTRY lock poisoned, aborting. This indicates a panic in merge code."
    );
    std::process::abort();
});

Consistent with write side behavior, prevents undefined behavior from propagating.

3. Dedicated StorageError::MergeError variant

Added semantic error type:

/// A merge operation failed.
#[error("Merge failed: {0}")]
MergeError(String),

ActionNotAllowed implied permission error; MergeError clearly indicates CRDT merge failure.

All tests pass, code is formatted and lints clean.

Copy link

@meroreviewer meroreviewer bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🤖 AI Code Reviewer

Reviewed by 3 agents | Quality score: 100% | Review time: 295.8s

🟡 3 warnings, 💡 1 suggestions, 📝 1 nitpicks. See inline comments.


🤖 Generated by AI Code Reviewer | Review ID: review-308c3873

Copy link

@meroreviewer meroreviewer bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🤖 AI Code Reviewer

Reviewed by 3 agents | Quality score: 95% | Review time: 344.3s


✅ No Issues Found

All agents reviewed the code and found no issues. LGTM! 🎉


🤖 Generated by AI Code Reviewer | Review ID: review-835eb0c5

Copy link

@meroreviewer meroreviewer bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🤖 AI Code Reviewer

Reviewed by 3 agents | Quality score: 92% | Review time: 334.2s

🟡 1 warnings, 💡 3 suggestions. See inline comments.


🤖 Generated by AI Code Reviewer | Review ID: review-4677e87e

cursor bot and others added 2 commits February 14, 2026 18:41
Co-authored-by: Cursor Agent <cursoragent@cursor.com>
Use std::sync::Once to ensure merge functions are only registered once,
even when called from multiple tests.
Copy link

@meroreviewer meroreviewer bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🤖 AI Code Reviewer

Reviewed by 3 agents | Quality score: 100% | Review time: 313.8s

💡 3 suggestions. See inline comments.


🤖 Generated by AI Code Reviewer | Review ID: review-597effef

Copy link
Contributor

@cursor cursor bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cursor Bugbot has reviewed your changes and found 1 potential issue.

Bugbot Autofix is ON. A Cloud Agent has been kicked off to fix the reported issue.

Copy link

@meroreviewer meroreviewer bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🤖 AI Code Reviewer

Reviewed by 3 agents | Quality score: 100% | Review time: 331.6s

🟡 1 warnings, 💡 2 suggestions. See inline comments.


🤖 Generated by AI Code Reviewer | Review ID: review-cf853fd0

@cursor
Copy link
Contributor

cursor bot commented Feb 14, 2026

Bugbot Autofix prepared fixes for 1 of the 1 bugs found in the latest run.

  • ✅ Fixed: Once guard prevents re-registration after registry clear
    • Removed the std::sync::Once guard from register_test_merge_functions() since register_crdt_merge() already handles duplicate registrations safely via HashMap::insert, making the function truly resilient to clear_merge_registry() calls.

View PR

Or push these changes by commenting:

@cursor push 89c70ea4fd
Preview (89c70ea4fd)
diff --git a/crates/storage/src/tests/common.rs b/crates/storage/src/tests/common.rs
--- a/crates/storage/src/tests/common.rs
+++ b/crates/storage/src/tests/common.rs
@@ -192,18 +192,14 @@
 /// This must be called before any test that triggers root entity merging.
 /// Required for I5 enforcement - without registration, merge operations will fail.
 ///
-/// This function is idempotent - safe to call multiple times.
+/// This function is idempotent - safe to call multiple times, even after
+/// `clear_merge_registry()` has been called.
 pub fn register_test_merge_functions() {
-    use std::sync::Once;
-    static INIT: Once = Once::new();
-
-    INIT.call_once(|| {
-        use crate::merge::register_crdt_merge;
-        register_crdt_merge::<Page>();
-        register_crdt_merge::<Paragraph>();
-        register_crdt_merge::<Person>();
-        register_crdt_merge::<EmptyData>();
-    });
+    use crate::merge::register_crdt_merge;
+    register_crdt_merge::<Page>();
+    register_crdt_merge::<Paragraph>();
+    register_crdt_merge::<Person>();
+    register_crdt_merge::<EmptyData>();
 }
 
 /// Helper to create a test keypair and public key.

Once prevents re-registration after clear_merge_registry() in #[serial]
tests, causing NoMergeFunctionRegistered errors. The registry's HashMap
insert is already idempotent, so repeated calls are safe.
Copy link

@meroreviewer meroreviewer bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🤖 AI Code Reviewer

Reviewed by 3 agents | Quality score: 83% | Review time: 263.5s

💡 2 suggestions. See inline comments.


🤖 Generated by AI Code Reviewer | Review ID: review-38bf7a24

Copy link

@meroreviewer meroreviewer bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🤖 AI Code Reviewer

Reviewed by 3 agents | Quality score: 80% | Review time: 341.1s

🟡 1 warnings, 💡 3 suggestions, 📝 1 nitpicks. See inline comments.


🤖 Generated by AI Code Reviewer | Review ID: review-eec572e3

@xilosada xilosada merged commit ddfef32 into master Feb 14, 2026
22 checks passed
@xilosada xilosada deleted the fix/i5-unregistered-root-merge branch February 14, 2026 18:30
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant