fix: reject dangling Relation/Page references at every write path#124
Conversation
Fixes vouchdev#123 Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
|
Important Review skippedAuto reviews are disabled on base/target branches other than the default branch. Please check the settings in the CodeRabbit UI or the ⚙️ Run configurationConfiguration used: defaults Review profile: CHILL Plan: Pro Plus Run ID: You can disable this status message by setting the Use the checkbox below for a quick retry:
✨ Finishing Touches🧪 Generate unit tests (beta)
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. Comment |
…-dangling-refs # Conflicts: # src/vouch/bundle.py
…-dangling-refs # Conflicts: # CHANGELOG.md # tests/test_bundle.py # tests/test_cli.py
|
Fix the conflict |
…-dangling-refs # Conflicts: # CHANGELOG.md # src/vouch/bundle.py # src/vouch/sync.py # tests/test_bundle.py # tests/test_health.py # tests/test_sync.py
done |
ReviewSummary: Closes #123 by enforcing referential integrity at every write path — What works
Suggestions
Verdictapprove — The fix is complete and correctly scoped. All five entry points from the issue are addressed; the test coverage (storage positive/negative, bundle self-contained + destination-resident + dangling, sync check + apply, proposal rejection) is thorough. The |
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.
What changed
Every write path that lands a
Relationor aPagenow rejects dangling foreign-id references instead of silently persisting them.relation.source/relation.targetmust resolve to an existing claim, page, entity, or source;relation.evidencemust resolve to a source or evidence;page.entitiesandpage.sourcesmust resolve (thepage.claimscheck already existed). The gate is enforced at five layers so no entry point is left open:store.put_relation,store.put_relation_idempotent,store.put_page(src/vouch/storage.py) — the durable write path used byproposals.approveandlifecycle.supersede/contradict.proposals.propose_relation,proposals.propose_page(src/vouch/proposals.py) — surface the same checks at proposal time as a friendlyProposalError, mirroring the existingpropose_claimevidence loop.bundle.import_check(src/vouch/bundle.py) — bundle import writes member bytes directly to disk, bypassing the storage layer, so a new_check_graph_integritycross-artifact pass resolves every incoming relation / page / claim reference against the post-merge id set (destination KB plus the bundle's own members).import_applyalready raises on the first issue, so it inherits the fix.sync.sync_check(src/vouch/sync.py) — runs the same cross-artifact pass against the destination KB.Why
Fixes #123.
relation.source/target,relation.evidence,page.entities, andpage.sourcescould all reference ids that resolve to nothing.health.lint/doctoralready reports the resultingdangling_relationas anerror-severity finding (src/vouch/health.py:135-145), but no write path enforced it — so the corrupt state was reachable throughpropose → approve, the lifecycle ops,bundle.import_apply, andsync.sync_apply, and a manifest-consistent bundle could ship a fully dangling knowledge graph withimport_check.ok == True. This is the same structural shape as #81 (uncited claims enforced only inpropose_claim): the invariant lived in one observer but was enforced by zero writers. This PR makes the writers enforce what the observer already reports — the prevention counterpart to the after-the-factvouch fsckdetection in #112.The endpoint surface is intentionally permissive about Source ids: sources are first-class graph nodes (they back claims and crystallize into pages), so a relation may point at a source id.
put_relation_idempotentdeliberately skips re-validation when the relation file already exists on disk, so asupersede/contradictretry doesn't spuriously fail if a linked claim was later archived or retracted.What might break
No on-disk-layout, schema,
kb.*method, bundle-format, or audit-log-shape change. Existing.vouch/directories are unaffected — nothing moves and no field changes shape. The only behavioural change is that data the model +health.lintalready say should never exist now raises at write time instead of landing silently:propose_relation/propose_pageraiseProposalErroron an unresolved reference (previously deferred to approve time, where it landed).put_relation/put_pageraiseValueErroron an unresolved reference.import_check/sync_checkreport adangling reference: …issue andimport_apply/sync_applyrefuse, the same way they already refuse a hash mismatch (bug: import_check / import_apply never verify member sha256 against manifest — bundle integrity gate is bypassable #74) or an uncited claim (bug: Claim model has no min-evidence validator — uncited claims land via bundle import, put_claim, update_claim #81).A KB that already contains a dangling relation on disk (written by the old code) is unaffected on read; it still surfaces as a
health.lintfinding. Legitimate self-contained bundles (shipping an entity alongside the relation that points at it) import cleanly because references resolve against the post-merge id set.VEP
Not required. No change to the object model,
kb.*method surface, on-disk layout, bundle format, or audit-log shape — this only tightens write-time validation to match an invariant the schema andhealth.lintalready document.Tests
make checkpasses locally (lint + mypy + pytest) — ruff clean, mypySuccess: no issues found in 31 source files, full pytest green on Windows / Python 3.13 except the pre-existingtest_read_under_root_rejects_symlink_at_resolved_pathwhich needs symlink-create privilege (unrelated to this change).tests/test_storage.py(put_relation / put_relation_idempotent / put_page + propose_relation / propose_page, positive and negative),tests/test_bundle.py(dangling relation / page rejection, self-contained bundle accepted, refs resolved against destination KB), andtests/test_sync.py(sync_check reports + sync_apply refuses).tests/test_health.py::test_lint_dangling_relationwas migrated to hand-write its dangling YAML, sinceput_relationno longer lets the old setup construct one.CHANGELOG.mdupdated under## [Unreleased].