Problem
Every write path that lands a Relation or a Page accepts dangling foreign-id references. relation.source, relation.target, relation.evidence, page.entities, and page.sources can all reference ids that resolve to nothing in the KB. health.lint / doctor reports the resulting dangling_relation finding after the fact (src/vouch/health.py:135-145), but no write path enforces it, so the corrupt state is reachable through every approval / import / sync path the system advertises.
This is the same structural shape as #81 (uncited claims): the invariant lives in one observer (health.lint) but is enforced by zero writers, so every write path silently produces what the reviewer is told the KB never contains.
End-to-end repro on test HEAD (6047ddc):
- Layer 1 propose_relation -> approve : source/target/evidence all dangling, landed
- Layer 2 storage.put_relation : dangling endpoints landed
- Layer 3 storage.put_page : dangling entities/sources landed
- Layer 4 bundle.import_apply : ok=True, dangling relation + page written
- Layer 5 sync.sync_apply : ok=True, dangling relation + page written
Affected paths:
- proposals.propose_relation (src/vouch/proposals.py:179-207) - no existence checks for src, target, or evidence ids. Compare propose_claim:91-98 which validates every evidence id resolves.
- proposals.propose_page (src/vouch/proposals.py:115-147) - no existence checks for claim_ids, entity_ids, source_ids.
- storage.put_relation / put_relation_idempotent (src/vouch/storage.py:399-440) - write the YAML with zero validation. Reached by proposals.approve and by lifecycle.supersede / lifecycle.contradict.
- storage.put_page (src/vouch/storage.py:342-354) - validates claims but not entities or sources.
- bundle.import_apply (src/vouch/bundle.py) - writes member bytes directly, bypassing the storage layer; _validate_content only checks field shapes, not whether referenced ids exist.
- sync.sync_apply (src/vouch/sync.py) - same pattern via bundle._validate_content.
The audit log records each landing as a normal approve / import event with the legitimate id, so an operator reading the trail has no signal the artifact references nothing.
Why this matters
- README "Object model" describes Relations as a "typed edge between entities / claims / pages" - the implicit invariant that source/target are such artifacts is what makes graph traversal (relations_from, relations_to, kb.context) meaningful.
- health.lint already calls out dangling_relation as an error-severity finding, so the team already treats this as a defect - it just isnt prevented at write time.
- The bundle / sync paths defeat the portable-bundle integrity story: a manifest-consistent bundle can ship a fully dangling knowledge graph and import_check.ok reports True.
This is the prevention counterpart to #96 / #112 (vouch fsck): fsck reports existing damage; this is about not landing new damage.
Suggested fix
Mirror the Claim.evidence + put_claim enforcement to the Relation / Page surface:
- storage.put_relation / put_relation_idempotent - verify source and target each resolve to a known claim / page / entity / source; verify every evidence id resolves to a source / evidence.
- storage.put_page - extend the existing claims loop to cover entities and sources.
- proposals.propose_relation / propose_page - surface the same checks earlier as ProposalError.
- bundle.import_check - after the per-file schema + sha256 pass, run a cross-artifact graph-integrity pass against the post-merge id set (destination KB plus incoming bundle); import_apply already raises on the first issue.
- sync.sync_check - same cross-artifact pass.
No on-disk-layout, schema, or bundle-format change - only data the model + observers already say should never land starts raising.
Problem
Every write path that lands a
Relationor aPageaccepts dangling foreign-id references.relation.source,relation.target,relation.evidence,page.entities, andpage.sourcescan all reference ids that resolve to nothing in the KB.health.lint/doctorreports the resultingdangling_relationfinding after the fact (src/vouch/health.py:135-145), but no write path enforces it, so the corrupt state is reachable through every approval / import / sync path the system advertises.This is the same structural shape as #81 (uncited claims): the invariant lives in one observer (
health.lint) but is enforced by zero writers, so every write path silently produces what the reviewer is told the KB never contains.End-to-end repro on test HEAD (6047ddc):
Affected paths:
The audit log records each landing as a normal approve / import event with the legitimate id, so an operator reading the trail has no signal the artifact references nothing.
Why this matters
This is the prevention counterpart to #96 / #112 (vouch fsck): fsck reports existing damage; this is about not landing new damage.
Suggested fix
Mirror the Claim.evidence + put_claim enforcement to the Relation / Page surface:
No on-disk-layout, schema, or bundle-format change - only data the model + observers already say should never land starts raising.