From 9c21ac01f6a71cce70f964720d6d417c0778adb5 Mon Sep 17 00:00:00 2001 From: Nick M <274344962+nickmopen@users.noreply.github.com> Date: Wed, 8 Jul 2026 04:22:07 +0300 Subject: [PATCH] fix(compile): drop same-batch duplicate page titles in compile_kb taken_names was computed once before the phase-1 validation loop and never grew as survivors were accepted, unlike phase-2 wikilink resolution just below it. two same-titled drafts in one compile_kb() LLM response both passed the collision guard and got filed as separate PAGE proposals with the same id; approving the first created the page and approving the second silently overwrote it via the update_page vault-edit path, with no error or diff shown to the reviewer. fold each survivor's title/slug into taken_names as soon as it's accepted, so a same-batch duplicate is dropped the same way a collision with an existing page or pending proposal already is. Fixes #439 --- CHANGELOG.md | 8 ++++++++ src/vouch/compile.py | 5 +++++ tests/test_compile.py | 21 +++++++++++++++++++++ 3 files changed, 34 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 986dcb0e..1c0a3e2e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,14 @@ All notable changes to vouch are documented here. Format follows ## [Unreleased] +### Fixed +- `compile_kb()` no longer lets two same-titled drafts in one LLM batch both + survive validation — the second collided with the first on approval and + silently overwrote it via the `update_page` vault-edit path. accepted + titles are now folded into `taken_names` as soon as they survive, so a + same-batch duplicate is dropped just like a collision with an existing + page or pending proposal. + ## [1.2.2] — 2026-07-07 ### Packaging diff --git a/src/vouch/compile.py b/src/vouch/compile.py index 8dbf4a86..369fabff 100644 --- a/src/vouch/compile.py +++ b/src/vouch/compile.py @@ -343,6 +343,11 @@ def compile_kb( report.dropped.append({"title": title, "reason": problem}) continue survivors.append((draft, title)) + # fold the accepted title into taken_names immediately: a later draft + # in this same batch colliding with it must be dropped too, mirroring + # how phase 2 folds survivors back in for wikilink resolution below. + taken_names.add(title.lower()) + taken_names.add(_slugify(title)) # phase 2: wikilinks resolve against existing pages + the *surviving* # batch, to a fixpoint — dropping a draft may dangle a link in another, diff --git a/tests/test_compile.py b/tests/test_compile.py index 64137a0d..13f0986f 100644 --- a/tests/test_compile.py +++ b/tests/test_compile.py @@ -317,6 +317,27 @@ def test_collision_with_pending_proposal_dropped( assert "pending review" in second.dropped[0]["reason"] +def test_same_batch_duplicate_titles_second_dropped( + store: KBStore, tmp_path: Path, +) -> None: + """Two same-titled drafts in one compile_kb() call must not both survive + validation — the second would collide with the first on approval and + silently overwrite it via the update_page vault-edit path (#439).""" + c1 = _approved_claim(store, "a fact") + report = compile_kb(store, config=_cfg(_stub_llm(tmp_path, [ + {"title": "Deploy Workflow", "type": "workflow", + "body": f"first body [claim: {c1}]", "claims": [c1]}, + {"title": "Deploy Workflow", "type": "workflow", + "body": f"SECOND BODY totally different [claim: {c1}]", + "claims": [c1]}, + ]))) + assert len(report.proposed) == 1 + assert "already exists" in report.dropped[0]["reason"] + + approve(store, report.proposed[0]["proposal_id"], approved_by="human-B") + assert store.get_page("deploy-workflow").body == f"first body [claim: {c1}]" + + def test_dropped_sibling_dangles_wikilink_and_cascades( store: KBStore, tmp_path: Path, ) -> None: