From 80ffdb6cd712b2edb8395d042aece266075a85ab Mon Sep 17 00:00:00 2001 From: davion-knight <298846663+davion-knight@users.noreply.github.com> Date: Tue, 7 Jul 2026 11:37:31 -0500 Subject: [PATCH] fix(lifecycle): reject claim self-contradiction contradict() had no guard against claim_a == claim_b, unlike its sibling supersede() which explicitly rejects self-reference. calling it with the same id on both sides wrote a self-loop contradicts reference and flipped the claim to contested with no real counterparty -- durable, nonsensical state with no way to recover short of manual yaml editing. --- CHANGELOG.md | 7 +++++++ src/vouch/lifecycle.py | 2 ++ tests/test_storage.py | 10 ++++++++++ 3 files changed, 19 insertions(+) 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]))