"""TDD — IdentityPlugin.merge() and merge_ops(). Three-way merge with I1 (acyclicity) enforcement. Invariant I1 is enforced during merge: if the merged result would contain a relationship set that introduces a cycle, the offending relationship is flagged as a conflict rather than auto-merged. The graph is never left in a cyclic state. Covers: - Disjoint identity adds on both branches → auto-merge (no conflicts) - Disjoint relationship adds on both branches → auto-merge - Both branches add same file identically → auto-merge (consensus) - Both branches modify same file differently → conflict - Merge result includes union of independent adds from both branches - I1: merge of a new relationship that closes a cycle → conflict, not applied - I1: merge of non-cyclic relationships in parallel → auto-merge - Deleted on one branch, unchanged on other → deletion wins - Deleted on both branches → deletion wins - domain field preserved in merged snapshot """ from __future__ import annotations import pathlib import pytest from muse.domain import SnapshotManifest from muse.core.types import Manifest, long_id from muse.plugins.identity.plugin import IdentityPlugin @pytest.fixture def plugin() -> IdentityPlugin: return IdentityPlugin() # ── snapshot helpers ────────────────────────────────────────────────────────── _CTR = 0 def _hash(tag: str) -> str: return long_id(f"{abs(hash(tag)):064x}") def snap(files: Manifest | None = None) -> SnapshotManifest: return SnapshotManifest( files=files or {}, domain="identity", directories=[], ) def with_files(*paths: str) -> SnapshotManifest: return snap({p: _hash(p) for p in paths}) def with_custom(files: Manifest) -> SnapshotManifest: return snap(files) # Canonical path helpers def id_path(handle: str) -> str: return f"identities/{handle}.json" def rel_path(frm: str, edge: str, to: str) -> str: return f"relationships/{frm}--{edge}--{to}.json" # ── disjoint adds → auto-merge ──────────────────────────────────────────────── class TestAutoMerge: def test_disjoint_identity_adds(self, plugin: IdentityPlugin) -> None: base = snap() left = with_files(id_path("gabriel")) right = with_files(id_path("alice")) result = plugin.merge(base, left, right) assert result.conflicts == [] assert id_path("gabriel") in result.merged["files"] assert id_path("alice") in result.merged["files"] def test_disjoint_relationship_adds(self, plugin: IdentityPlugin) -> None: base = snap() left = with_files(rel_path("gabriel", "spawns", "bot-1")) right = with_files(rel_path("alice", "spawns", "bot-2")) result = plugin.merge(base, left, right) assert result.conflicts == [] assert rel_path("gabriel", "spawns", "bot-1") in result.merged["files"] assert rel_path("alice", "spawns", "bot-2") in result.merged["files"] def test_both_add_same_file_identically(self, plugin: IdentityPlugin) -> None: base = snap() same_hash = _hash("gabriel") left = snap({id_path("gabriel"): same_hash}) right = snap({id_path("gabriel"): same_hash}) result = plugin.merge(base, left, right) assert result.conflicts == [] assert id_path("gabriel") in result.merged["files"] def test_add_identity_and_relationship_disjoint(self, plugin: IdentityPlugin) -> None: base = snap() left = with_files(id_path("gabriel")) right = with_files(rel_path("alice", "member_of", "acme")) result = plugin.merge(base, left, right) assert result.conflicts == [] assert len(result.merged["files"]) == 2 def test_domain_preserved_in_merged(self, plugin: IdentityPlugin) -> None: base = snap() left = with_files(id_path("gabriel")) right = with_files(id_path("alice")) result = plugin.merge(base, left, right) assert result.merged["domain"] == "identity" # ── same-file conflict ───────────────────────────────────────────────────────── class TestConflict: def test_both_modify_same_identity_differently(self, plugin: IdentityPlugin) -> None: base = with_custom({id_path("gabriel"): _hash("v1")}) left = with_custom({id_path("gabriel"): _hash("v2-left")}) right = with_custom({id_path("gabriel"): _hash("v2-right")}) result = plugin.merge(base, left, right) assert id_path("gabriel") in result.conflicts def test_both_modify_same_relationship_differently(self, plugin: IdentityPlugin) -> None: p = rel_path("alice", "member_of", "acme") base = with_custom({p: _hash("weight-1")}) left = with_custom({p: _hash("weight-2")}) right = with_custom({p: _hash("weight-3")}) result = plugin.merge(base, left, right) assert p in result.conflicts # ── deletions ───────────────────────────────────────────────────────────────── class TestDeletion: def test_deleted_on_left_unchanged_on_right(self, plugin: IdentityPlugin) -> None: p = id_path("alice") base = with_files(p) left = snap() # deleted right = with_files(p) # unchanged result = plugin.merge(base, left, right) assert result.conflicts == [] assert p not in result.merged["files"] def test_deleted_on_both_branches(self, plugin: IdentityPlugin) -> None: p = id_path("alice") base = with_files(p) left = snap() right = snap() result = plugin.merge(base, left, right) assert result.conflicts == [] assert p not in result.merged["files"] def test_unchanged_on_both_branches_preserved(self, plugin: IdentityPlugin) -> None: p = id_path("alice") h = _hash("alice") base = with_custom({p: h}) left = with_custom({p: h}) right = with_custom({p: h}) result = plugin.merge(base, left, right) assert result.conflicts == [] assert result.merged["files"][p] == h # ── I1 Acyclicity enforcement ───────────────────────────────────────────────── class TestI1AcyclicityEnforcement: """Merge must reject a relationship that would create a cycle. The plugin embeds relationship path → (from, edge, to) decoding and maintains the cumulative edge set from the merged snapshot to detect I1 violations before accepting a new relationship into the merge result. """ def test_cycle_in_merged_result_becomes_conflict(self, plugin: IdentityPlugin) -> None: # Base: gabriel --spawns--> bot-1 # Left: (unchanged) # Right: adds bot-1 --spawns--> gabriel (would close a cycle) existing = rel_path("gabriel", "spawns", "bot-1") cycle_edge = rel_path("bot-1", "spawns", "gabriel") base = with_files(existing) left = with_files(existing) right = with_files(existing, cycle_edge) result = plugin.merge(base, left, right) # cycle_edge must be rejected, not silently applied assert cycle_edge in result.conflicts assert cycle_edge not in result.merged["files"] def test_self_loop_becomes_conflict(self, plugin: IdentityPlugin) -> None: self_loop = rel_path("gabriel", "spawns", "gabriel") base = snap() left = snap() right = with_files(self_loop) result = plugin.merge(base, left, right) assert self_loop in result.conflicts assert self_loop not in result.merged["files"] def test_indirect_cycle_three_nodes_rejected(self, plugin: IdentityPlugin) -> None: # a→b, b→c already committed; merge tries to add c→a a_b = rel_path("a", "spawns", "b") b_c = rel_path("b", "spawns", "c") c_a = rel_path("c", "spawns", "a") # closes cycle base = with_files(a_b, b_c) left = with_files(a_b, b_c) right = with_files(a_b, b_c, c_a) result = plugin.merge(base, left, right) assert c_a in result.conflicts assert c_a not in result.merged["files"] def test_non_cyclic_new_edge_is_accepted(self, plugin: IdentityPlugin) -> None: # a→b already; add b→c (linear chain, no cycle) a_b = rel_path("a", "spawns", "b") b_c = rel_path("b", "spawns", "c") base = with_files(a_b) left = with_files(a_b) right = with_files(a_b, b_c) result = plugin.merge(base, left, right) assert result.conflicts == [] assert b_c in result.merged["files"] def test_diamond_dag_not_a_cycle(self, plugin: IdentityPlugin) -> None: # alice→a1, alice→a2, a1→target, a2→target — valid DAG (diamond) alice_a1 = rel_path("alice", "spawns", "a1") alice_a2 = rel_path("alice", "spawns", "a2") a1_target = rel_path("a1", "spawns", "target") a2_target = rel_path("a2", "spawns", "target") base = with_files(alice_a1, alice_a2, a1_target) left = with_files(alice_a1, alice_a2, a1_target) right = with_files(alice_a1, alice_a2, a1_target, a2_target) result = plugin.merge(base, left, right) assert result.conflicts == [] assert a2_target in result.merged["files"] def test_member_of_cycle_rejected(self, plugin: IdentityPlugin) -> None: # org-a → org-b (member_of); merge tries org-b → org-a a_to_b = rel_path("org-a", "member_of", "org-b") b_to_a = rel_path("org-b", "member_of", "org-a") base = with_files(a_to_b) left = with_files(a_to_b) right = with_files(a_to_b, b_to_a) result = plugin.merge(base, left, right) assert b_to_a in result.conflicts def test_cross_edge_type_cycle_rejected(self, plugin: IdentityPlugin) -> None: # alice --spawns--> bot; merge tries bot --member_of--> alice # spawns + member_of edges share the same DAG universe spawns_edge = rel_path("alice", "spawns", "bot") back_edge = rel_path("bot", "member_of", "alice") base = with_files(spawns_edge) left = with_files(spawns_edge) right = with_files(spawns_edge, back_edge) result = plugin.merge(base, left, right) assert back_edge in result.conflicts # ── HarmonyPlugin fingerprinting ────────────────────────────────────────────── class TestHarmonyFingerprint: """IdentityPlugin implements HarmonyPlugin for semantic conflict fingerprinting. Two conflicts that have the same structural shape (same edge being added) but different signature timestamps should produce the same fingerprint — enabling Harmony to replay the resolution automatically. """ def test_plugin_has_conflict_fingerprint(self, plugin: IdentityPlugin) -> None: assert hasattr(plugin, "conflict_fingerprint") def test_same_structural_conflict_same_fingerprint(self, plugin: IdentityPlugin, tmp_path: pathlib.Path) -> None: # Same edge added, different signature timestamps → same fingerprint import json from muse.core.types import blob_id from muse.plugins.identity.records import RelationshipRecord, record_to_bytes rec1 = RelationshipRecord( from_handle="gabriel", to_handle="bot", edge_type="spawns", weight=None, authorized_by=[{"signer": "gabriel", "signature": "ed25519:AAA", "signed_at": "2026-01-01T00:00:00Z"}], ) rec2 = RelationshipRecord( from_handle="gabriel", to_handle="bot", edge_type="spawns", weight=None, authorized_by=[{"signer": "gabriel", "signature": "ed25519:BBB", "signed_at": "2026-06-01T00:00:00Z"}], ) p = tmp_path / "relationships" / "gabriel--spawns--bot.json" p.parent.mkdir(parents=True) p.write_bytes(record_to_bytes(rec1)) ours_id = blob_id(record_to_bytes(rec1)) theirs_id = blob_id(record_to_bytes(rec2)) fp1 = plugin.conflict_fingerprint( "relationships/gabriel--spawns--bot.json", ours_id, theirs_id, tmp_path ) fp2 = plugin.conflict_fingerprint( "relationships/gabriel--spawns--bot.json", theirs_id, ours_id, tmp_path ) assert fp1 == fp2 # commutative def test_different_structural_conflict_different_fingerprint(self, plugin: IdentityPlugin, tmp_path: pathlib.Path) -> None: from muse.core.types import blob_id from muse.plugins.identity.records import RelationshipRecord, record_to_bytes rec_spawn = RelationshipRecord( from_handle="gabriel", to_handle="bot-1", edge_type="spawns", weight=None, authorized_by=[], ) rec_member = RelationshipRecord( from_handle="gabriel", to_handle="acme", edge_type="member_of", weight="1", authorized_by=[], ) tmp_path.joinpath("relationships").mkdir(parents=True, exist_ok=True) def fp(rec: RelationshipRecord, path: str) -> str: p = tmp_path / path p.parent.mkdir(parents=True, exist_ok=True) p.write_bytes(record_to_bytes(rec)) ours_id = blob_id(record_to_bytes(rec)) theirs_id = blob_id(b'other') return plugin.conflict_fingerprint(path, ours_id, theirs_id, tmp_path) fp_spawn = fp(rec_spawn, "relationships/gabriel--spawns--bot-1.json") fp_member = fp(rec_member, "relationships/gabriel--member_of--acme.json") assert fp_spawn != fp_member