Skip to content

Commit d6ef0df

Browse files
authored
Merge pull request #707 from philluiz2323/fix/vault-sync-second-edit-clobbered
fix(vault-sync): key pending-proposal dedup on edit content, not page id
2 parents 42a2b0c + db063b4 commit d6ef0df

3 files changed

Lines changed: 78 additions & 13 deletions

File tree

CHANGELOG.md

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -106,6 +106,18 @@ All notable changes to vouch are documented here. Format follows
106106
artifact the caller could not already retrieve, and it touches no write path.
107107

108108
### Fixed
109+
- **vault sync no longer clobbers a second, distinct vault edit made while
110+
the first edit's proposal is still pending**: `_has_pending_page_proposal`
111+
dedup-checked pending proposals by page id alone, so re-running
112+
`vault_to_kb` after a *different* edit to an already-pending page
113+
silently skipped filing a new proposal instead of recognizing the edit
114+
as distinct. The second edit was never captured in any proposal, and
115+
the next backward sync pass then overwrote the vault mirror with the
116+
KB's still-unapproved-first-edit content, discarding the second edit
117+
with no trace and no error. Now keyed on the content-address (sha256)
118+
of the whole edit rather than the page id alone, matching how sources
119+
are already fingerprinted elsewhere, so a second distinct edit correctly
120+
files its own proposal instead of being coalesced into the first.
109121
- **`kb.experts` no longer leaks out-of-scope claims into entity rankings**
110122
(#714): `rank_experts` aggregated evidence density over every claim in
111123
the KB with no viewer/scope filtering at all, unlike every sibling

src/vouch/vault_sync.py

Lines changed: 23 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -314,15 +314,22 @@ def kb_to_vault(store: KBStore, vault_dir: Path) -> VaultSyncResult:
314314

315315

316316
def _has_pending_page_proposal(
317-
store: KBStore, page_id: str, *, body: str | None = None
317+
store: KBStore, page_id: str, *, source_id: str | None = None
318318
) -> bool:
319319
"""Return True if a pending proposal already targets ``page_id``.
320320
321-
When ``body`` is supplied, only returns True if the pending proposal
322-
also carries the same body — allowing a second different vault edit
323-
to file a new proposal even while the first is still pending.
324-
Prevents duplicate proposals when vault_to_kb runs multiple times
325-
before the reviewer approves the first proposal for a given page edit.
321+
When ``source_id`` is supplied, only returns True if the pending
322+
proposal also cites that vault-edit source — allowing a second,
323+
*different* vault edit to file its own proposal even while the first
324+
is still pending, rather than being silently coalesced into it and
325+
then clobbered by the next backward sync pass. The source id is the
326+
content-address (sha256) of the whole mirror file `put_source` would
327+
assign — the canonical fingerprint of the complete edit (title, type,
328+
tags, claims, entities, body), not just the body text. Without this
329+
guard, running vault_to_kb twice before the first proposal is
330+
approved files duplicate proposals for the same edit, cluttering the
331+
review queue and causing the second approve to fail with "page
332+
already exists".
326333
"""
327334
from .models import ProposalKind, ProposalStatus
328335
for proposal in store.list_proposals(ProposalStatus.PENDING):
@@ -331,7 +338,7 @@ def _has_pending_page_proposal(
331338
payload = proposal.payload
332339
if not isinstance(payload, dict) or payload.get("id") != page_id:
333340
continue
334-
if body is None or payload.get("body") == body:
341+
if source_id is None or source_id in (payload.get("sources") or []):
335342
return True
336343
return False
337344

@@ -398,12 +405,15 @@ def vault_to_kb(
398405
result.pages_skipped_unknown_id.append(rel)
399406
continue
400407

401-
# Fix 2 (#219): skip if a pending proposal already targets this page
402-
# id. Without this guard, running vault_to_kb twice before the first
403-
# proposal is approved files duplicate proposals for the same edit,
404-
# cluttering the review queue and causing the second approve to fail
405-
# with "page already exists".
406-
if _has_pending_page_proposal(store, page_id):
408+
# Fix 2 (#219, refined by #706): skip if a pending proposal already
409+
# targets this page id *with this exact edit* (source_id=current_hash
410+
# — the content address the vault-edit source below will get).
411+
# Without the source_id check, a second, different edit made while
412+
# the first proposal is still pending would be misclassified as the
413+
# same duplicate, skipped without a new proposal, and then silently
414+
# clobbered by the next backward sync pass restoring the mirror to
415+
# canonical KB content — the second edit vanishing with no trace.
416+
if _has_pending_page_proposal(store, page_id, source_id=current_hash):
407417
log.debug(
408418
"vault sync: pending proposal already exists for page %r; "
409419
"skipping to avoid duplicate",

tests/test_vault_sync.py

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -590,6 +590,49 @@ def test_vault_to_kb_deduplicates_pending_proposals(
590590
)
591591

592592

593+
def test_vault_to_kb_files_new_proposal_for_second_distinct_edit(
594+
store: KBStore, vault: Path,
595+
) -> None:
596+
"""A second, *different* vault edit made while the first edit's proposal
597+
is still pending must get its own proposal, not be silently coalesced
598+
into the first (and then lost once a later backward pass reverts the
599+
mirror to canonical KB content, since the KB itself never learned about
600+
the second edit)."""
601+
kb_to_vault(store, vault)
602+
mirror = vault / VAULT_DIR / "pages" / "alpha-page.md"
603+
base_text = mirror.read_text(encoding="utf-8")
604+
605+
edit_a = base_text.replace("Original body.", "Edit A.")
606+
mirror.write_text(edit_a, encoding="utf-8")
607+
r1 = vault_to_kb(store, vault, actor="vault-sync")
608+
assert "alpha-page" in r1.pages_proposed
609+
proposals_after_first = list((store.kb_dir / "proposed").glob("*.yaml"))
610+
assert len(proposals_after_first) == 1
611+
612+
# A different edit lands before the first proposal is approved (e.g. the
613+
# user tweaks the page again on the next sync tick).
614+
edit_b = base_text.replace("Original body.", "Edit B, unrelated to A.")
615+
mirror.write_text(edit_b, encoding="utf-8")
616+
r2 = vault_to_kb(store, vault, actor="vault-sync")
617+
assert "alpha-page" in r2.pages_proposed, (
618+
"a distinct second edit must file its own proposal instead of "
619+
"being skipped as a duplicate of the first"
620+
)
621+
proposals_after_second = list((store.kb_dir / "proposed").glob("*.yaml"))
622+
assert len(proposals_after_second) == 2, (
623+
f"expected 2 proposals (one per distinct edit), got "
624+
f"{len(proposals_after_second)}"
625+
)
626+
627+
bodies = set()
628+
for path in proposals_after_second:
629+
import yaml
630+
payload = yaml.safe_load(path.read_text(encoding="utf-8"))
631+
bodies.add(payload["payload"]["body"])
632+
assert any("Edit A." in b for b in bodies)
633+
assert any("Edit B, unrelated to A." in b for b in bodies)
634+
635+
593636
def test_vault_to_kb_warns_on_claim_stub_edit(
594637
store: KBStore, vault: Path, caplog: pytest.LogCaptureFixture,
595638
) -> None:

0 commit comments

Comments
 (0)