diff --git a/CHANGELOG.md b/CHANGELOG.md index 986dcb0e..f1d22f1e 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 (or same-slugifying) page + drafts in a single LLM response both survive validation. The second + draft is now dropped as a collision, same as if it collided with a + pre-existing page — previously, approving both proposals in order would + silently overwrite the first approved page via the `update_page` + vault-edit path, with no error or diff surfaced to the reviewer. + ## [1.2.2] — 2026-07-07 ### Packaging diff --git a/src/vouch/compile.py b/src/vouch/compile.py index 8dbf4a86..2a1b37ad 100644 --- a/src/vouch/compile.py +++ b/src/vouch/compile.py @@ -343,6 +343,12 @@ 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 the same batch colliding with this one must be dropped, + # same as if it collided with a pre-existing page (see + # _draft_problem's collision-guard comment above). + 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..281dec3d 100644 --- a/tests/test_compile.py +++ b/tests/test_compile.py @@ -301,6 +301,26 @@ def test_collision_with_existing_page_dropped( assert store.get_page("deploy-workflow").body == before +def test_collision_within_same_batch_dropped( + store: KBStore, tmp_path: Path, +) -> None: + """Two same-titled drafts in one compile_kb() call must not both survive — + approving the first creates the page, approving the second would silently + overwrite it via the update_page vault-edit path (see #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 [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_collision_with_pending_proposal_dropped( store: KBStore, tmp_path: Path, ) -> None: