diff --git a/CHANGELOG.md b/CHANGELOG.md index 986dcb0e..a9e7cb77 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,13 @@ All notable changes to vouch are documented here. Format follows ## [Unreleased] +### Fixed +- `lifecycle.contradict()` no longer lets a claim contradict itself. calling + it with the same claim id on both sides previously wrote a self-loop + `contradicts` reference and flipped the claim to `contested` with no + actual counterparty; it now raises `LifecycleError`, mirroring the + existing guard in `supersede()`. + ## [1.2.2] — 2026-07-07 ### Packaging diff --git a/src/vouch/lifecycle.py b/src/vouch/lifecycle.py index fadbb072..969e3beb 100644 --- a/src/vouch/lifecycle.py +++ b/src/vouch/lifecycle.py @@ -76,6 +76,8 @@ def contradict( actor: str, ) -> tuple[Claim, Claim, Relation]: """Record that two claims contradict each other (symmetric).""" + if claim_a == claim_b: + raise LifecycleError("a claim cannot contradict itself") a = store.get_claim(claim_a) b = store.get_claim(claim_b) a.contradicts = sorted({*a.contradicts, b.id}) diff --git a/tests/test_storage.py b/tests/test_storage.py index 7e0ccdd6..c86e2ec6 100644 --- a/tests/test_storage.py +++ b/tests/test_storage.py @@ -871,6 +871,16 @@ def test_contradict_marks_both_contested(store: KBStore) -> None: assert rel.relation == RelationType.CONTRADICTS +def test_contradict_rejects_self_reference(store: KBStore) -> None: + src = store.put_source(b"e") + store.put_claim(Claim(id="a", text="x", evidence=[src.id])) + with pytest.raises(lifecycle.LifecycleError, match="cannot contradict itself"): + lifecycle.contradict(store, claim_a="a", claim_b="a", actor="u") + claim = store.get_claim("a") + assert claim.contradicts == [] + assert claim.status != ClaimStatus.CONTESTED + + def test_archive_changes_status(store: KBStore) -> None: src = store.put_source(b"e") store.put_claim(Claim(id="c1", text="x", evidence=[src.id]))