From 26974e33b82223edabcb6eccb57a9038de813b14 Mon Sep 17 00:00:00 2001 From: RealDiligent Date: Tue, 7 Jul 2026 12:02:16 +0800 Subject: [PATCH 1/3] fix: resolve dangling refs on vault page updates via update_page update_page lacked the graph-integrity ref checks that put_page and update_claim already enforce. Vault-edit approvals could land pages citing claims deleted between propose and approve. Co-authored-by: Cursor --- src/vouch/storage.py | 20 +++++++++++++++++++- tests/test_storage.py | 31 +++++++++++++++++++++++++++++++ 2 files changed, 50 insertions(+), 1 deletion(-) diff --git a/src/vouch/storage.py b/src/vouch/storage.py index b2214d7b..6af44c12 100644 --- a/src/vouch/storage.py +++ b/src/vouch/storage.py @@ -522,7 +522,14 @@ def update_claim(self, claim: Claim) -> Claim: # --- pages ------------------------------------------------------------- - def put_page(self, page: Page) -> Page: + def _validate_page_refs(self, page: Page) -> None: + """Reject dangling graph references on a Page before it lands. + + Mirrors `_validate_claim_refs` and the write-time gate on + `put_relation`: page edits (vault-sync approve path via + `update_page`) must not reintroduce the gap that `put_page` already + closed for new pages. + """ for cid in page.claims: if not self._claim_path(cid).exists(): raise ValueError(f"page {page.id} references unknown claim {cid}") @@ -532,6 +539,9 @@ def put_page(self, page: Page) -> Page: for sid in page.sources: if not (self._source_dir(sid) / "meta.yaml").exists(): raise ValueError(f"page {page.id} references unknown source {sid}") + + def put_page(self, page: Page) -> Page: + self._validate_page_refs(page) try: # Explicit UTF-8: page bodies are user / agent prose and routinely # contain non-ASCII (em-dashes, smart quotes, unicode in claims). @@ -561,6 +571,14 @@ def update_page(self, page: Page) -> Page: """ if not self._page_path(page.id).exists(): raise ArtifactNotFoundError(f"page {page.id}") + # Re-validate the in-memory Page before persisting so model + # invariants hold even when a caller mutated fields in place after + # get_page(). Field validators only run at construction time. + Page.model_validate(page.model_dump(mode="json")) + # Re-check graph references too: an in-place mutation (or a claim + # deleted between propose and approve on the vault-edit path) can + # introduce dangling links that the model validator can't catch. + self._validate_page_refs(page) self._page_path(page.id).write_text( _serialize_page(page), encoding="utf-8" ) diff --git a/tests/test_storage.py b/tests/test_storage.py index ce775688..be7148a1 100644 --- a/tests/test_storage.py +++ b/tests/test_storage.py @@ -615,6 +615,37 @@ def test_check_approvable_clean_for_well_formed_proposal( assert check_approvable(store, pr.id, approved_by="reviewer") is None +def test_update_page_rejects_dangling_claim_ref(store: KBStore) -> None: + """update_page must mirror put_page's ref guard — vault edits land here.""" + src = store.put_source(b"e") + store.put_claim(Claim(id="c1", text="t", evidence=[src.id])) + store.put_page( + Page(id="p1", title="T", body="body", type=PageType.CONCEPT, claims=["c1"]), + ) + bad = Page( + id="p1", title="T", body="updated", type=PageType.CONCEPT, claims=["ghost"], + ) + with pytest.raises(ValueError, match="unknown claim"): + store.update_page(bad) + + +def test_approve_page_update_rejects_stale_claim_ref(store: KBStore) -> None: + """A claim deleted between propose and approve must not become a dangling + page reference — the vault-edit path routes through update_page.""" + src = store.put_source(b"e") + store.put_claim(Claim(id="c1", text="t", evidence=[src.id])) + store.put_page( + Page(id="p1", title="T", body="body", type=PageType.CONCEPT, claims=["c1"]), + ) + pr = propose_page( + store, title="T", body="updated", claim_ids=["c1"], + proposed_by="vault-sync", slug_hint="p1", + ) + (store.kb_dir / "claims" / "c1.yaml").unlink() + with pytest.raises(ValueError, match="unknown claim"): + approve(store, pr.id, approved_by="reviewer") + + # --- evidence ------------------------------------------------------------- From 0baa38a0256ff7bfed0637f9aca7208ab855b83b Mon Sep 17 00:00:00 2001 From: RealDiligent Date: Mon, 13 Jul 2026 19:49:37 +0800 Subject: [PATCH 2/3] fix(proposals): run payload precheck in approve() for single-approve paths Batch CLI already calls check_approvable(); web/MCP approve() did not, so stale vault-edit refs surfaced as ValueError instead of ProposalError. Defense-in-depth alongside update_page ref validation. Co-authored-by: Cursor --- src/vouch/proposals.py | 3 +++ tests/test_storage.py | 2 +- 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/src/vouch/proposals.py b/src/vouch/proposals.py index e66c1298..5e34a5f1 100644 --- a/src/vouch/proposals.py +++ b/src/vouch/proposals.py @@ -511,6 +511,9 @@ def approve( """ proposal = store.get_proposal(proposal_id) block = _approval_block_reason(store, proposal, approved_by) + if block: + raise ProposalError(block) + block = _payload_block_reason(store, proposal) if block: raise ProposalError(block) payload = dict(proposal.payload) diff --git a/tests/test_storage.py b/tests/test_storage.py index be7148a1..3e9718d5 100644 --- a/tests/test_storage.py +++ b/tests/test_storage.py @@ -642,7 +642,7 @@ def test_approve_page_update_rejects_stale_claim_ref(store: KBStore) -> None: proposed_by="vault-sync", slug_hint="p1", ) (store.kb_dir / "claims" / "c1.yaml").unlink() - with pytest.raises(ValueError, match="unknown claim"): + with pytest.raises(ProposalError, match="unknown claim"): approve(store, pr.id, approved_by="reviewer") From 0219eb96e690ef5c92240c931755e96c7b7e019c Mon Sep 17 00:00:00 2001 From: RealDiligent Date: Mon, 13 Jul 2026 19:52:57 +0800 Subject: [PATCH 3/3] test(delete): broaden approve recheck assertion for payload precheck approve() now runs _payload_block_reason before delete, so the error message uses referenced by instead of still referenced by. Co-authored-by: Cursor --- tests/test_delete.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/test_delete.py b/tests/test_delete.py index 31b5d7de..d2bd5ed5 100644 --- a/tests/test_delete.py +++ b/tests/test_delete.py @@ -267,7 +267,7 @@ def test_approve_rechecks_reference_added_after_propose(store: KBStore) -> None: pr = propose_delete(store, target_kind="claim", target_id="c1", proposed_by="agent") # a page starts referencing c1 AFTER the proposal was filed store.put_page(Page(id="p1", title="P", body="", claims=["c1"])) - with pytest.raises(ProposalError, match="still referenced"): + with pytest.raises(ProposalError, match="referenced"): approve(store, pr.id, approved_by="reviewer") # target survives, proposal stays pending assert store._claim_path("c1").exists()