Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
27 changes: 27 additions & 0 deletions src/vouch/lifecycle.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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,
Expand Down Expand Up @@ -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
Expand Down
52 changes: 52 additions & 0 deletions tests/test_storage.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 == []

Comment on lines +863 to +913

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🗄️ Data Integrity & Integration | 🟡 Minor | ⚡ Quick win

assert complete persisted-state immutability on rejection.

The archived case only checks that old was not superseded. Snapshot both claims and relations before each rejected call, then compare their persisted values afterward; this protects against future partial writes.

As per path instructions, “add/adjust regression tests that assert the correct failure mode ... and that no on-disk lifecycle state is mutated on failure.”

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@tests/test_storage.py` around lines 863 - 913, The rejection tests in
test_supersede_rejects_archived_new_claim and
test_contradict_rejects_retired_claim only verify one status, not complete
persistence immutability. Snapshot both involved claims and their lifecycle
relations before each expected LifecycleError, then compare all persisted fields
and relations afterward to confirm the rejected operation leaves on-disk state
unchanged.

Source: Path instructions


📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

move these lifecycle tests to the matching test module.

These tests cover src/vouch/lifecycle.py, so they belong in tests/test_lifecycle.py, not tests/test_storage.py.

As per coding guidelines, “Tests must mirror module names using the strict tests/test_<module>.py convention.”

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@tests/test_storage.py` around lines 863 - 913, The lifecycle tests
test_supersede_rejects_retired_new_claim,
test_supersede_rejects_archived_new_claim, and
test_contradict_rejects_retired_claim are in the wrong module. Move these test
functions and their required imports/fixtures to tests/test_lifecycle.py,
removing them from tests/test_storage.py while preserving their behavior and
assertions.

Source: Coding guidelines


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]))
Expand Down
Loading