Skip to content

fix(storage): reject dangling Claim graph references on every write path - #201

Merged
plind-junior merged 5 commits into
vouchdev:testfrom
joaovictor712:fix/claim-dangling-graph-refs
Jun 22, 2026
Merged

fix(storage): reject dangling Claim graph references on every write path#201
plind-junior merged 5 commits into
vouchdev:testfrom
joaovictor712:fix/claim-dangling-graph-refs

Conversation

@joaovictor712

@joaovictor712 joaovictor712 commented Jun 10, 2026

Copy link
Copy Markdown
Contributor

What changed

KBStore._validate_claim_refs rejects a Claim whose entities, supersedes, superseded_by, or contradicts point at an artifact not in the KB, called from both put_claim and update_claim. bundle.import_check gains the matching check on the claim branch. lifecycle.supersede / contradict pre-validate both touched claims before the first disk write (atomicity). proposals.check_approvable dry-runs the same ref guards so vouch approve a b stays all-or-nothing. vouch fsck reports claim.entities pointing at a missing entity as a new dangling_claim_entity error finding.

Why

Fixes #196. Claim's four graph-ref fields were validated by no writer — put_claim checked only evidence, update_claim's model re-check has no KB access, bundle.import_apply writes claim YAML directly, and proposals.approve inherits the gap. Meanwhile fsck already declares dangling_supersedes / _superseded_by / _contradicts as error-severity findings, so the invariant was articulated but enforced by no writer — same shape as #81 / #123 / #124.

What might break

Strictly additive write-time validation. Honest writes unaffected; only Claims with dangling graph refs start being rejected.

  • put_claim / update_claim raise ValueError("claim <id> references unknown entity/claim …").
  • bundle.import_check reports a dangling reference: … claim … issue; import_apply refuses.
  • lifecycle.supersede / contradict raise before any disk write if either claim has a dangling ref (atomicity).
  • check_approvable returns the same reason for batch precheck.
  • Legacy on-disk poisoned claims still surface via vouch fsck (now including dangling_claim_entity).

VEP

Not required — tightens write-time validation to match an invariant fsck already documents.

Tests

  • make check passes locally (ruff, mypy 54 files, affected pytest files green).
  • New / changed behaviour has tests in tests/test_storage.py (graph-ref guard, lifecycle atomicity, check_approvable precheck), tests/test_bundle.py (bundle reject + accept), tests/test_health.py (dangling_claim_entity + existing dangling-chain tests now write claim YAML directly).
  • CHANGELOG.md updated under ## [Unreleased] ### Fixed.

Closes #196.

joaovictor712 and others added 2 commits June 10, 2026 08:34
Claim's four graph-reference fields — entities, supersedes, superseded_by,
contradicts — were validated by no write path. put_claim checked only
claim.evidence; update_claim re-validated the model (no KB access, so no
ref check); bundle.import_apply writes claim YAML straight to disk. The
vouchdev#124 graph-integrity fix closed Relation.source/target/evidence and
Page.entities/sources but, as its own storage.py comment shows, skipped
the Claim's own reference fields — even though fsck already declares
dangling_supersedes / dangling_superseded_by / dangling_contradicts as
error-severity findings. The invariant was articulated but enforced by
no writer (same shape as vouchdev#81 / vouchdev#123).

- storage.KBStore._validate_claim_refs: entities -> entity ids,
  supersedes/contradicts/superseded_by -> claim ids. Called from both
  put_claim and update_claim (the latter closes the in-place-mutation
  reach path, mirroring the vouchdev#82 re-validation fix).
- bundle.import_check: extend the claim branch of the graph-integrity
  pass with the same checks, matching the existing page-ref checks, so a
  bundle can't land a dangling claim ref through import_apply's direct
  write.
- Honest lifecycle writes are unaffected: supersede/contradict load both
  ends via get_claim before linking, so their refs always resolve.

Regression tests in test_storage.py, test_bundle.py, and test_health.py;
the fsck/CLI dangling-chain tests now write poisoned YAML directly to disk
to reproduce the legacy on-disk state fsck must still surface.

Closes vouchdev#196.
@coderabbitai

coderabbitai Bot commented Jun 15, 2026

Copy link
Copy Markdown

Important

Review skipped

Auto reviews are disabled on base/target branches other than the default branch.

Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: bfd7706a-26d5-4d92-8991-0acab7c2b3d0

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review
✨ 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 and usage tips.

The `test` merge restored this test to the put_claim(Claim(supersedes=
["ghost"])) form, which now trips the new _validate_claim_refs guard and
fails CI. Write the dangling-ref claim YAML directly to disk instead — the
same approach already used by the fsck dangling tests in test_health.py —
so it reproduces the legacy/poisoned on-disk state fsck must still surface,
without relying on a write path that now (correctly) rejects it.
@joaovictor712
joaovictor712 force-pushed the fix/claim-dangling-graph-refs branch from c82a5f7 to d3a134b Compare June 16, 2026 09:11
@plind-junior

Copy link
Copy Markdown
Member

@codex review

@chatgpt-codex-connector chatgpt-codex-connector 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.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: d3a134bdbc

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread src/vouch/storage.py
raise ValueError(
f"claim {claim.id} cites unknown source/evidence {cid_or_sid}"
)
self._validate_claim_refs(claim)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Precheck claim refs before batch approval

With this new write-time guard, a claim proposal whose entities payload contains a typo (possible through MCP/JSONL kb.propose_claim, since propose_claim only validates evidence) now fails only when approve() reaches store.put_claim. check_approvable() still returns None for that proposal, so the default vouch approve a b path passes the all-or-nothing precheck and then catches this ValueError while continuing to approve later IDs, contradicting the documented “nothing was approved” semantics. Please validate claim entity refs before filing/prechecking proposals, or include this guard in check_approvable().

Useful? React with 👍 / 👎.

Comment thread src/vouch/storage.py
Comment on lines +373 to +375
for eid in claim.entities:
if not self._entity_path(eid).exists():
raise ValueError(

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Surface legacy claim entity refs in fsck

When this starts rejecting claim.entities, any existing KB that already has a claim pointing at a deleted or misspelled entity can no longer archive/confirm/update that claim, but vouch fsck still only checks the claim lifecycle fields (supersedes, superseded_by, contradicts) and never reports dangling claim entities. Please add a lint/fsck finding for these entity refs alongside the new write-time gate so users have a preflight repair path instead of discovering the blocker only when an unrelated update fails.

Useful? React with 👍 / 👎.

Comment thread src/vouch/storage.py
# dangling entities/supersedes/superseded_by/contradicts link that the
# model validator can't catch (it has no KB access). Mirrors the
# put_claim guard so the update path can't reintroduce the gap.
self._validate_claim_refs(claim)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Avoid partial lifecycle writes on legacy refs

Because update_claim() now raises on any existing dangling graph ref, lifecycle operations can leave a half-applied state when the second claim is legacy/poisoned. For example, vouch supersede old new writes old.status = superseded first, then store.update_claim(new) raises if new.entities or another graph field points at a missing artifact, so the KB records old.superseded_by = new without the reciprocal new.supersedes, relation, or audit event. Please pre-validate all touched claims before the first write or make the lifecycle update atomic.

Useful? React with 👍 / 👎.

@plind-junior

Copy link
Copy Markdown
Member

@galuis116 Would you fix codex review?

…check, fsck coverage

- lifecycle.supersede / contradict pre-validate both touched claims via
  _validate_claim_refs before the first disk write so a legacy dangling
  ref can't half-apply the operation (P2 vouchdev#3).
- proposals.check_approvable dry-runs the put_*-side ref guards via a
  new _payload_block_reason helper so the default `vouch approve a b`
  batch flow catches a dangling claim.entities ref BEFORE any write,
  preserving the all-or-nothing contract (P2 vouchdev#1).
- health.fsck reports claim.entities pointing at a missing entity as a
  new `dangling_claim_entity` error finding, giving operators a
  preflight repair path for legacy KBs (P2 vouchdev#2).
@joaovictor712

Copy link
Copy Markdown
Contributor Author

@plind-junior Pushed 778dc26 addressing all three Codex P2 findings.

P2 #3lifecycle.supersede / contradict atomicity (the actual correctness regression):
Both ops now call store._validate_claim_refs(old) / _validate_claim_refs(new) (or a / b) before the first update_claim. If either side has a legacy dangling graph ref, both ops bail before any disk write — no more half-applied old.superseded_by without the reciprocal new.supersedes, no orphaned relation, no audit event for a partial operation.

P2 #1check_approvable batch precheck:
New _payload_block_reason(store, proposal) in proposals.py dry-runs the same put_*-side ref guards approve() would hit (claim entity/graph refs, relation endpoints, page claim/entity/source refs, model-layer construction). check_approvable chains it after _approval_block_reason. The default vouch approve a b all-or-nothing flow now catches a dangling claim.entities ref BEFORE touching disk, preserving the "nothing was approved" contract.

P2 #2 — fsck coverage for claim.entities:
New _check_claim_graph_refs(claims, entities, findings) in health.py reports dangling_claim_entity as an error-severity finding, alongside the existing dangling_supersedes / _superseded_by / _contradicts checks. Gives operators a preflight repair path for legacy KBs once the write-time guard rejects un-updatable claims.

Diff: +343 / −3 across lifecycle.py / health.py / proposals.py / tests/test_storage.py / tests/test_health.py / CHANGELOG.md.

Tests (6 new in test_storage.py, 1 new in test_health.py):

  • test_supersede_atomic_when_new_has_legacy_dangling_ref — asserts old is not touched, no relation file written, no claim.supersede audit event.
  • test_contradict_atomic_when_b_has_legacy_dangling_ref — same shape for contradict.
  • test_check_approvable_catches_claim_with_dangling_entity_ref — proposal whose payload has a typo'd entity is blocked at precheck.
  • test_check_approvable_catches_relation_proposal_filed_directly — defense-in-depth for proposals filed via put_proposal directly (bypassing propose_relation's own gate).
  • test_check_approvable_clean_for_well_formed_proposal — positive guard.
  • test_fsck_flags_dangling_claim_entity — new fsck finding.

ruff check src tests clean, mypy src clean (54 source files, requires [dev,web] extras for jinja2 — the bare [dev] install leaves it unresolved as the existing pyproject comment notes), pytest green across test_storage / test_health / test_cli / test_sessions / test_sync / test_verify / test_bundle (one pre-existing test_import_apply_rejects_absolute_path regex-match failure exists on test and on this branch independent of these changes — confirmed by git stash + rerun on the clean PR HEAD).

CHANGELOG entry added under ## [Unreleased] ### Fixed with three bullets, one per Codex item, citing #201 / #196 lineage.

@plind-junior

Copy link
Copy Markdown
Member

@galuis116 Thanks for the contribution. Would you optimize the comments and description in this PR?

@joaovictor712

Copy link
Copy Markdown
Contributor Author

@plind-junior Done — pushed 38d4023. PR description rewritten (~1.7k chars, no more inline annotations); inline docstrings on lifecycle.supersede / contradict, health._check_claim_graph_refs, proposals._payload_block_reason, and the followup tests collapsed to 1-2 lines each; CHANGELOG bullets cut to one paragraph each. Net: -129/+31 across the six touched files. Behaviour and tests unchanged.

@plind-junior
plind-junior merged commit 0c10b4b into vouchdev:test Jun 22, 2026
5 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants