diff --git a/CHANGELOG.md b/CHANGELOG.md index 0704c6a4..a744fadb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -64,6 +64,20 @@ All notable changes to vouch are documented here. Format follows per-prompt block, the session banner, `vouch status` and the opt-in question all say so rather than calling it "this repo's" knowledge. +### Fixed +- `lifecycle.supersede()` and `lifecycle.contradict()` no longer accept an + already-retired claim (superseded/archived/redacted) as a live + participant. previously, `supersede(old, new)` never checked `new`'s + status, so `supersede(a, b)` followed by `supersede(b, a)` silently + closed a 2-cycle — both claims ended up `status: superseded`, each + pointing at the other, and `context._RETRACTED_CLAIM_STATUSES` then + excluded *both* from every retrieval surface with no live successor. + separately, `contradict(a, b)` unconditionally set both sides to + `CONTESTED` — which is not in the retracted-status set — so + contradicting an already-superseded/archived/redacted claim silently + un-retired it back into live retrieval. both now raise + `LifecycleError` instead of writing the inconsistent state. + ## [1.5.0] — 2026-07-20 ### Added diff --git a/src/vouch/lifecycle.py b/src/vouch/lifecycle.py index 90a9cb2c..6181eeff 100644 --- a/src/vouch/lifecycle.py +++ b/src/vouch/lifecycle.py @@ -14,6 +14,7 @@ from datetime import UTC, datetime from . import audit +from .context import _RETRACTED_CLAIM_STATUSES from .models import Claim, ClaimStatus, Evidence, Relation, RelationType from .storage import ArtifactNotFoundError, KBStore @@ -34,6 +35,19 @@ def supersede( raise LifecycleError("a claim cannot supersede itself") old = store.get_claim(old_claim_id) new = store.get_claim(new_claim_id) + # `new` must be live knowledge. Without this guard, superseding with an + # already-retired claim (superseded/archived/redacted) either revives it + # as a "current" successor with stale retired status, or — when `new` + # was itself the `old` side of a prior supersede() call — closes a + # 2-cycle: A superseded_by B and B superseded_by A. context._RETRACTED_ + # CLAIM_STATUSES then excludes *both* ends from retrieval, and neither + # is ever the live head, silently vanishing knowledge no one asked to + # delete. + if new.status in _RETRACTED_CLAIM_STATUSES: + raise LifecycleError( + f"cannot supersede with {new.id}: it is already " + f"{new.status.value}, not live knowledge" + ) rel = Relation( id=f"{new.id}--supersedes--{old.id}", source=new.id, @@ -80,6 +94,19 @@ def contradict( raise LifecycleError("a claim cannot contradict itself") a = store.get_claim(claim_a) b = store.get_claim(claim_b) + # Neither side may already be retired (superseded/archived/redacted). + # CONTESTED is not in _RETRACTED_CLAIM_STATUSES (still part of the + # conversation, just disputed — see context.py), so setting status to + # CONTESTED unconditionally would silently un-retire a claim straight + # back into every retrieval surface, making the supersede/archive/ + # redact controls decorative for anyone who later contradicts a retired + # claim against something live. + for claim in (a, b): + if claim.status in _RETRACTED_CLAIM_STATUSES: + raise LifecycleError( + f"cannot contradict {claim.id}: it is already " + f"{claim.status.value}, not live knowledge" + ) a.contradicts = sorted({*a.contradicts, b.id}) b.contradicts = sorted({*b.contradicts, a.id}) a.status = ClaimStatus.CONTESTED diff --git a/tests/test_storage.py b/tests/test_storage.py index ce775688..57022b5d 100644 --- a/tests/test_storage.py +++ b/tests/test_storage.py @@ -860,6 +860,58 @@ def test_supersede_idempotent(store: KBStore) -> None: assert store.get_claim("new").supersedes == ["old"] +def test_supersede_rejects_retired_new_claim(store: KBStore) -> None: + # Guards against a 2-cycle: A superseded_by B, then B "superseded_by" A. + # Before the fix this silently corrupted both claims to + # status=superseded, pointing at each other — retrieval excludes + # SUPERSEDED, so both claims vanished from every retrieval surface with + # no live successor. + src = store.put_source(b"e") + store.put_claim(Claim(id="a", text="a", evidence=[src.id])) + store.put_claim(Claim(id="b", text="b", evidence=[src.id])) + lifecycle.supersede(store, old_claim_id="a", new_claim_id="b", actor="u") + with pytest.raises(lifecycle.LifecycleError, match="already superseded"): + lifecycle.supersede(store, old_claim_id="b", new_claim_id="a", actor="u") + # Neither claim's state was touched by the rejected call. + a = store.get_claim("a") + b = store.get_claim("b") + assert a.status == ClaimStatus.SUPERSEDED + assert a.superseded_by == "b" + assert a.supersedes == [] + assert b.status != ClaimStatus.SUPERSEDED + assert b.superseded_by is None + + +def test_supersede_rejects_archived_new_claim(store: KBStore) -> None: + src = store.put_source(b"e") + store.put_claim(Claim(id="old", text="old", evidence=[src.id])) + store.put_claim(Claim(id="new", text="new", evidence=[src.id])) + lifecycle.archive(store, claim_id="new", actor="u") + with pytest.raises(lifecycle.LifecycleError, match="already archived"): + lifecycle.supersede(store, old_claim_id="old", new_claim_id="new", actor="u") + assert store.get_claim("old").status != ClaimStatus.SUPERSEDED + + +def test_contradict_rejects_retired_claim(store: KBStore) -> None: + # A retired (superseded/archived/redacted) claim must not be revivable + # into CONTESTED — CONTESTED is not in the retracted-status set, so + # before the fix this silently un-retired the claim back into every + # retrieval surface (context.py's _RETRACTED_CLAIM_STATUSES exclusion). + src = store.put_source(b"e") + store.put_claim(Claim(id="old", text="old", evidence=[src.id])) + store.put_claim(Claim(id="new", text="new", evidence=[src.id])) + store.put_claim(Claim(id="c", text="c", evidence=[src.id])) + lifecycle.supersede(store, old_claim_id="old", new_claim_id="new", actor="u") + with pytest.raises(lifecycle.LifecycleError, match="already superseded"): + lifecycle.contradict(store, claim_a="old", claim_b="c", actor="u") + old = store.get_claim("old") + c = store.get_claim("c") + assert old.status == ClaimStatus.SUPERSEDED + assert old.contradicts == [] + assert c.status != ClaimStatus.CONTESTED + assert c.contradicts == [] + + def test_contradict_marks_both_contested(store: KBStore) -> None: src = store.put_source(b"e") store.put_claim(Claim(id="a", text="x", evidence=[src.id]))