Skip to content

fix(lifecycle): reject supersede/contradict on already-retired claims#539

Closed
rsnetworkinginc wants to merge 1 commit into
vouchdev:testfrom
rsnetworkinginc:fix/lifecycle-retired-claim-guard
Closed

fix(lifecycle): reject supersede/contradict on already-retired claims#539
rsnetworkinginc wants to merge 1 commit into
vouchdev:testfrom
rsnetworkinginc:fix/lifecycle-retired-claim-guard

Conversation

@rsnetworkinginc

@rsnetworkinginc rsnetworkinginc commented Jul 21, 2026

Copy link
Copy Markdown

summary

lifecycle.supersede() and lifecycle.contradict() never checked the current status of the claim on the "becomes live" side of the call, which let two related inconsistent states land on disk:

  • supersede cycle: supersede(old, new) never checked new's status. Calling supersede(a, b) and then supersede(b, a) silently closes a 2-cycle — both claims end up status: superseded, each pointing at the other via superseded_by/supersedes. context._RETRACTED_CLAIM_STATUSES excludes SUPERSEDED from every retrieval surface, so both claims vanish from kb.context/kb.search/recall with no live successor — knowledge lost without anyone asking to delete it.
  • contradict resurrects retired claims: contradict(a, b) unconditionally sets both sides to CONTESTED, which is not in the retracted-status set (contested claims are still part of the conversation, just disputed). Contradicting an already-superseded/archived/redacted claim against anything else silently un-retires it straight back into live retrieval, making the supersede/archive/redact controls decorative.

Reproduced by hand against a scratch .vouch/ (not from a filed issue — found by exercising the CLI directly, same approach as #509).

what changed

src/vouch/lifecycle.py:

  • supersede(): raises LifecycleError if new's current status is already superseded/archived/redacted, mirroring the existing old_claim_id == new_claim_id self-reference guard.
  • contradict(): raises LifecycleError if either side's current status is already superseded/archived/redacted, mirroring the self-reference guard added in fix(lifecycle): reject claim self-contradiction #437.

No object-model, kb.* surface, on-disk layout, or audit-log shape change — both are existing lifecycle functions, this only tightens their pre-write validation (same shape as #437's self-contradiction fix).

Nothing breaks for an existing .vouch/ directory: pre-existing (already-corrupted) data on disk is untouched; the guard only blocks new calls that would create the inconsistent state going forward.

test plan

New regression tests in tests/test_storage.py:

  • test_supersede_rejects_retired_new_claim — reproduces the 2-cycle, asserts LifecycleError and that neither claim's state changed.
  • test_supersede_rejects_archived_new_claim
  • test_contradict_rejects_retired_claim

Confirmed each fails on the pre-fix code (git stash the lifecycle.py change) and passes after.

python -m pytest tests/ -q --ignore=tests/embeddings
python -m mypy src
python -m ruff check src tests

All green.

changelog

Added a ### Fixed entry under ## [Unreleased] in CHANGELOG.md.

Summary by CodeRabbit

  • Bug Fixes

    • Prevented lifecycle operations from using retired claims as active participants.
    • Invalid supersede and contradict actions now fail safely without creating inconsistent or reactivated claim states.
    • Preserved existing claim data when these operations are rejected.
  • Documentation

    • Updated the unreleased changelog with the lifecycle behavior changes.

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 as its
successor. context._RETRACTED_CLAIM_STATUSES then excludes SUPERSEDED
from every retrieval surface, so both claims vanish with no live head
- knowledge lost without anyone asking to delete it.

separately, contradict(a, b) unconditionally set both sides to
CONTESTED, which is not in the retracted-status set. contradicting an
already-superseded/archived/redacted claim against anything else
silently un-retired it back into live retrieval, making the
supersede/archive/redact controls decorative.

both functions now raise LifecycleError when a claim on the "becomes
live" side of the call is already retired, mirroring the existing
self-reference guards.

validation:
python -m pytest tests/ -q --ignore=tests/embeddings
python -m mypy src
python -m ruff check src tests
all green. new regression tests confirmed red pre-fix / green post-fix
via git stash.
@github-actions github-actions Bot added docs documentation, specs, examples, and repo guidance storage kb storage, migrations, schemas, and proposals tests tests and fixtures size: S 50-199 changed non-doc lines labels Jul 21, 2026
@coderabbitai

coderabbitai Bot commented Jul 21, 2026

Copy link
Copy Markdown

Review Change Stack

Walkthrough

Changes

Lifecycle guards

Layer / File(s) Summary
Lifecycle status validation
src/vouch/lifecycle.py
supersede() and contradict() reject retired claims with LifecycleError before writing lifecycle state.
Regression coverage and changelog
tests/test_storage.py, CHANGELOG.md
Tests cover superseded and archived claims, persisted-state preservation, and the documented lifecycle behavior.

Estimated code review effort: 2 (Simple) | ~10 minutes

Suggested reviewers: plind-junior, dripsmvcp

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly and accurately summarizes the main lifecycle validation change for retired claims.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Actionable comments posted: 2

🤖 Prompt for all review comments with 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.

Inline comments:
In `@tests/test_storage.py`:
- Around line 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.
- Around line 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.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: 39cb40d9-bda9-4cc6-91b6-ec640f73a851

📥 Commits

Reviewing files that changed from the base of the PR and between 1c9ca7c and 9532300.

📒 Files selected for processing (3)
  • CHANGELOG.md
  • src/vouch/lifecycle.py
  • tests/test_storage.py

Comment thread tests/test_storage.py
Comment on lines +863 to +913
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 == []

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

@github-actions

Copy link
Copy Markdown

closing automatically: CodeRabbit requested changes and this pr has had no new commits for 2 days. the feedback still stands — push a fix and reopen this pr (or open a fresh one) and it will be reviewed again.

@github-actions github-actions Bot closed this Jul 24, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

docs documentation, specs, examples, and repo guidance size: S 50-199 changed non-doc lines storage kb storage, migrations, schemas, and proposals tests tests and fixtures

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant