Skip to content

fix(validation): reject dangling foreign-id refs on every write path - #152

Closed
greatjourney589 wants to merge 2 commits into
vouchdev:mainfrom
greatjourney589:fix/validate-foreign-refs-on-write-123
Closed

fix(validation): reject dangling foreign-id refs on every write path#152
greatjourney589 wants to merge 2 commits into
vouchdev:mainfrom
greatjourney589:fix/validate-foreign-refs-on-write-123

Conversation

@greatjourney589

@greatjourney589 greatjourney589 commented Jun 3, 2026

Copy link
Copy Markdown

What changed

Added foreign-ID reference validation on every write path that touches relations, pages, and proposals. storage.put_relation and storage.put_page now verify that all referenced entity, source, claim, and evidence IDs actually exist before writing. proposals.propose_relation and propose_page do the same at proposal time. bundle.import_check gained a new _check_graph_integrity pass that mirrors these invariants for incoming bundle members against the merged (disk + bundle) ID space.

Why

Previously, it was possible to write a relation pointing to a non-existent entity or a page referencing an unknown source without any error. This left the KB in a state where graph traversal and export would silently produce incomplete or incorrect results.

Fixes #123.

What might break

No on-disk layout or field shape changes. Existing .vouch/ directories are unaffected on read. However, any code that previously wrote relations or pages with dangling foreign IDs will now receive a ValueError (storage layer) or ProposalError (proposals layer). Bundle imports that contain dangling refs will now produce issues entries and return ok=False from import_check.

VEP

Not required — no surface change (no new kb.* methods, no model field additions, no on-disk format change).

Tests

  • make check passes locally (lint + mypy + pytest)
  • New / changed behaviour has a test
  • CHANGELOG.md updated under ## [Unreleased]

Summary by CodeRabbit

Release Notes

  • New Features

    • Enhanced data integrity validation for bundle imports, ensuring all cross-artifact references are valid.
    • Added validation for page and relation proposals to verify referenced artifacts exist.
    • Implemented storage-level validation to enforce referential integrity for pages and relations.
  • Tests

    • Added comprehensive test coverage for graph integrity checks and foreign key validation across bundle operations, proposals, and storage.

@coderabbitai

coderabbitai Bot commented Jun 3, 2026

Copy link
Copy Markdown

Review Change Stack

Warning

Review limit reached

@greatjourney589, we couldn't start this review because you've reached your PR review rate limit.

More reviews will be available in 31 minutes and 27 seconds. Learn how PR review limits work.

Your organization has run out of usage credits. Purchase more in the billing tab.

⌛ How to resolve this issue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

We recommend that you space out your commits to avoid hitting the rate limit.

🚦 How do rate limits work?

CodeRabbit enforces hourly rate limits for each developer per organization.

Our paid plans include higher PR review limits than trial, open-source, and free plans. In all cases, reviews become available again over time. During sustained high-volume PR review activity, CodeRabbit may temporarily slow when the next review becomes available.

Please see our Fair Usage Limits Policy for further information.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: baec6d37-cfb5-4d42-a51a-495486d38471

📥 Commits

Reviewing files that changed from the base of the PR and between 96b41f6 and 028a181.

📒 Files selected for processing (3)
  • src/vouch/bundle.py
  • src/vouch/storage.py
  • tests/test_bundle.py
📝 Walkthrough

Walkthrough

This PR adds validation across three layers of the Vouch system to prevent Relations and Pages from referencing non-existent artifacts. The storage layer validates endpoints and evidence; the proposals layer performs early validation; bundle imports run cross-artifact graph integrity checks; and comprehensive tests verify all write paths reject dangling references.

Changes

Referential Integrity Validation Across Write Paths

Layer / File(s) Summary
Storage layer validation foundations
src/vouch/storage.py
New _relation_referable and _validate_relation helpers verify that relation endpoints and evidence resolve to valid artifacts; put_relation, put_relation_idempotent, and put_page now call these validators before persisting, raising ValueError for unresolved IDs.
Proposals API validation
src/vouch/proposals.py
New _resolve_artifact helper checks whether an artifact ID exists in the KB; propose_page validates claim_ids, entity_ids, and source_ids; propose_relation validates src and target endpoints plus evidence, raising ProposalError early if any reference unresolved artifacts.
Bundle import graph integrity
src/vouch/bundle.py
New _check_graph_integrity function validates relations and pages against a merged id space (destination KB plus incoming bundle); import_check collects bundle members and runs the integrity check, flagging dangling relations and pages before returning results.
Comprehensive test coverage
tests/test_storage.py, tests/test_bundle.py, tests/test_health.py
Storage and proposal tests validate that dangling endpoints, evidence, entities, and sources are rejected with appropriate errors; bundle tests verify graph integrity detection; health test adapted to simulate pre-existing corrupt state via direct disk write since the API now rejects invalid relations.

Sequence Diagram(s)

sequenceDiagram
  participant Client
  participant ProposalAPI as propose_relation<br/>propose_page
  participant StorageAPI as put_relation<br/>put_page
  participant BundleAPI as import_check
  participant KBStore as KB Store
  Client->>ProposalAPI: submit relation/page with artifact IDs
  ProposalAPI->>KBStore: _resolve_artifact(id)
  KBStore-->>ProposalAPI: exists? true/false
  ProposalAPI->>StorageAPI: put_relation/put_page
  StorageAPI->>KBStore: _relation_referable(id)
  KBStore-->>StorageAPI: resolvable? true/false
  StorageAPI->>KBStore: write YAML to disk
  Client->>BundleAPI: import bundle with relations/pages
  BundleAPI->>KBStore: _check_graph_integrity(bundle_members)
  KBStore-->>BundleAPI: dangling refs found? error list
  BundleAPI-->>Client: import check result (ok/issues)
Loading

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~25 minutes

Possibly related PRs

  • vouchdev/vouch#34: Both PRs modify src/vouch/bundle.py's bundle import flow (import_check/import_apply) to add validation before committing artifacts—PR #34 focuses on Pydantic schema validation while this PR adds cross-artifact graph referential-integrity checks.

Suggested reviewers

  • plind-junior

Poem

🐰 A bunny's checks, both near and far,
Ensure each artifact's a genuine star,
No dangling threads in graph's bright dance—
Each claim, each page, gets validation's chance,
Through storage, proposals, and bundle's embrace,
Referential integrity finds its place!

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 16.67% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title 'fix(validation): reject dangling foreign-id refs on every write path' accurately describes the main change—adding validation to reject dangling foreign-key references across all write paths.
Linked Issues check ✅ Passed The PR successfully addresses all primary coding objectives from issue #123: validation on storage writes (put_relation, put_page), proposals (propose_relation, propose_page), and bundle imports (import_check).
Out of Scope Changes check ✅ Passed All changes are within the scope of issue #123. No unrelated refactoring, UI changes, or functionality outside the validation enforcement are present.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ 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.

@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: 1

🧹 Nitpick comments (2)
src/vouch/storage.py (1)

436-443: ⚡ Quick win

_validate_relation runs before the idempotent existence short-circuit.

The docstring promises this path can "converge to a consistent state on retry without raising if the relation file was already written." But _validate_relation is now called unconditionally before the path.exists() early-return. If an already-written relation is re-applied after one of its endpoints was removed, the converge/retry now raises ValueError instead of succeeding — weakening the idempotency guarantee. Consider validating only when actually writing a new file.

♻️ Move validation after the existence check
-        self._validate_relation(rel)
         path = self._relation_path(rel.id)
         if path.exists():
             self._embed_and_store(
                 kind="relation", id=rel.id,
                 text=f"{rel.source} {rel.relation.value} {rel.target}",
             )
             return rel
+        self._validate_relation(rel)
         try:
             with path.open("x") as f:

Please confirm whether lifecycle ops (supersede/contradict) can re-invoke put_relation_idempotent for an existing relation after an endpoint deletion, which would make the eager validation a regression.

🤖 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 `@src/vouch/storage.py` around lines 436 - 443, The code calls
self._validate_relation(rel) before checking if the relation file already exists
via path = self._relation_path(rel.id) and path.exists(), which breaks
idempotency; change the flow in put_relation_idempotent (or the method
containing this diff) to perform the existence short-circuit first (compute path
and if path.exists(): return rel) and only call self._validate_relation(rel) and
self._embed_and_store(...) when the file does not exist, so validation only runs
on writes and preserves the promised converge-on-retry behavior.
tests/test_bundle.py (1)

407-432: ⚡ Quick win

Move repeated import to module level.

The import from vouch.storage import _serialize_page appears twice (lines 411 and 425). Move it to the top of the file alongside other imports for cleaner code organization.

♻️ Proposed fix

At the top of the file (after line 16), add:

 from vouch.models import Claim, Entity, EntityType, Page, Relation, RelationType
 from vouch.storage import KBStore
+from vouch.storage import _serialize_page

Then remove the inline imports at lines 411 and 425:

 def test_import_check_flags_page_with_unknown_entity(
     store: KBStore, tmp_path: Path
 ) -> None:
     page = Page(id="p1", title="T", entities=["ghost-entity"])
-    from vouch.storage import _serialize_page
     page_bytes = _serialize_page(page).encode()
 def test_import_check_flags_page_with_unknown_source(
     store: KBStore, tmp_path: Path
 ) -> None:
     page = Page(id="p1", title="T", sources=["deadbeef" * 8])
-    from vouch.storage import _serialize_page
     page_bytes = _serialize_page(page).encode()
🤖 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_bundle.py` around lines 407 - 432, The tests currently import
_serialize_page inline in two places inside
test_import_check_flags_page_with_unknown_entity and
test_import_check_flags_page_with_unknown_source; move the import "from
vouch.storage import _serialize_page" to the module-level imports at the top of
tests/test_bundle.py (alongside the other imports) and remove the two duplicate
inline imports inside those test functions so both tests use the single
top-level _serialize_page import.
🤖 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 `@src/vouch/bundle.py`:
- Around line 302-319: The loop that validates pages in bundle import only
checks page.entities and page.sources but not page.claims, so imported pages can
reference missing claims; in the loop that deserializes pages (using
_deserialize_page over bundle_members) add a check iterating page.claims and for
each cid not in claim_ids append an issue like the existing messages (e.g.,
"graph integrity: page {page.id} references unknown claim {cid!r}") so
page→claim references are validated against the merged claim_ids set just like
entities and sources.

---

Nitpick comments:
In `@src/vouch/storage.py`:
- Around line 436-443: The code calls self._validate_relation(rel) before
checking if the relation file already exists via path =
self._relation_path(rel.id) and path.exists(), which breaks idempotency; change
the flow in put_relation_idempotent (or the method containing this diff) to
perform the existence short-circuit first (compute path and if path.exists():
return rel) and only call self._validate_relation(rel) and
self._embed_and_store(...) when the file does not exist, so validation only runs
on writes and preserves the promised converge-on-retry behavior.

In `@tests/test_bundle.py`:
- Around line 407-432: The tests currently import _serialize_page inline in two
places inside test_import_check_flags_page_with_unknown_entity and
test_import_check_flags_page_with_unknown_source; move the import "from
vouch.storage import _serialize_page" to the module-level imports at the top of
tests/test_bundle.py (alongside the other imports) and remove the two duplicate
inline imports inside those test functions so both tests use the single
top-level _serialize_page import.
🪄 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: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 405f0a42-3a0f-4a35-a1e1-8768c32d98a7

📥 Commits

Reviewing files that changed from the base of the PR and between 3beb821 and 96b41f6.

📒 Files selected for processing (6)
  • src/vouch/bundle.py
  • src/vouch/proposals.py
  • src/vouch/storage.py
  • tests/test_bundle.py
  • tests/test_health.py
  • tests/test_storage.py

Comment thread src/vouch/bundle.py Outdated
@plind-junior

Copy link
Copy Markdown
Member

Review

Summary: Adds referential-integrity enforcement across all write paths for Relation and Page artifacts. The core direction is correct and the approach is clean. Two issues need attention before merge: a gap in put_relation_idempotent at the storage layer, and a missing coverage leg for the sync write path the issue explicitly called out.

What works

  • bundle.py:231-329_check_graph_integrity correctly builds a merged id-space (disk + incoming bundle) before checking, so cross-bundle relations that self-resolve pass cleanly. The test test_import_check_passes_for_relation_with_valid_endpoints_in_bundle and test_import_check_resolves_relation_endpoint_against_existing_kb cover both resolution modes.
  • bundle.py:372-382 — collecting bundle_members before calling _check_graph_integrity and placing the call just before the return is the right insertion point; import_apply delegates to import_check, so the gate is already in the apply path (confirmed by test_import_apply_refuses_dangling_relation).
  • storage.py:387-418_relation_referable uses path-existence checks (no deserialization) and _validate_relation is called in put_relation, matching the pattern established by put_evidence. Factoring out the helper is the right call.
  • storage.py:332-343put_page now validates entities and sources to mirror the existing claims loop. The symmetric addition keeps the three ref fields consistently guarded.
  • proposals.py:191-208_resolve_artifact mirrors _relation_referable at the proposal layer and is called eagerly before the proposal is filed, so ProposalError is raised before any disk write.
  • tests/test_health.py:30-40 — correctly adapts the existing test_lint_dangling_relation test by writing directly to disk to simulate pre-existing corrupt state now that put_relation rejects it. The comment makes the intent clear.
  • Test matrix is thorough: every new guard has at least one rejection test and one acceptance test.

Suggestions

  • [blocking] storage.py:440 (diff context: put_relation_idempotent) — _validate_relation is placed in the new-write branch only, after the if path.exists() early return that handles the already-exists case. That early return is the correct idempotent path (the relation file already passed validation when it was first written), so this is fine at a glance. However, the early-return branch also skips validation when the file exists on disk but was written before this PR landed — meaning corrupt legacy relations can still be "touched" by lifecycle ops (supersede/contradict) without the guard firing. This is a pre-existing state problem, but the issue description (Layer 5: put_relation_idempotent via lifecycle ops) lists it as in-scope. Consider checking whether path.exists() and the on-disk relation already passes _validate_relation before returning early, or documenting that the early-return branch is intentionally exempt from the new invariant. As written it silently widens the gap.

  • [blocking] bundle.py / missing sync path — Issue validation gap: every write path lands Relations/Pages with dangling foreign-id references #123 explicitly lists sync.sync_apply (Layer 5) as an affected path. The source tree has no sync.py, so either sync was removed before this PR or the issue description was written against a stale snapshot. If sync was removed, add a one-line comment in _check_graph_integrity noting the sync path is gone. If it still exists under a different name, that path needs the same graph-integrity gate. Either way the PR checklist item for sync.sync_check should be explicitly closed out.

  • [non-blocking] bundle.py:271pass # schema issues are already caught by _validate_content silently swallows parse errors during id-set construction. This means a malformed entity YAML in the bundle won't add its id to entity_ids, potentially producing a false-positive integrity issue for any relation that legitimately references it. The comment is accurate (schema shape is caught elsewhere), but the failure mode is worth noting: the graph-integrity pass produces a spurious error rather than a silent miss, which is the safer direction. No change needed, just worth calling out.

  • [non-blocking] proposals.py:193-208 (_resolve_artifact) — get_page is called without any arguments other than artifact_id, but the type-ignore comment # type: ignore[call-arg] suggests the signature may differ. Confirm this resolves to store.get_page(artifact_id) correctly (it does: get_page(self, page_id: str)), and consider dropping the type-ignore if mypy passes cleanly with it removed.

Verdict

Request changes — The put_relation_idempotent early-return gap (blocking) and the unresolved sync path question (blocking) both need explicit resolution. The core implementation is solid and the test coverage is genuinely good; these two items are clarifications/tightening rather than rewrites.

@greatjourney589

Copy link
Copy Markdown
Author

Hi,@plind-junior
I understood your suggestion exactly.when you reopen this PR, I will resolve it perfectly.

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.

validation gap: every write path lands Relations/Pages with dangling foreign-id references

2 participants