diff --git a/CHANGELOG.md b/CHANGELOG.md index 986dcb0e..ce8eae51 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,12 @@ All notable changes to vouch are documented here. Format follows ## [Unreleased] +### Fixed +- `compile_kb()` no longer lets two same-titled drafts in one LLM response + both survive the collision guard — the second draft is now dropped instead + of silently overwriting the first-approved page via the PAGE `update_page` + fallback in `approve()`. (#439) + ## [1.2.2] — 2026-07-07 ### Packaging diff --git a/src/vouch/compile.py b/src/vouch/compile.py index 8dbf4a86..a898cb99 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 — otherwise a + # second draft later in the same batch with a colliding title passes + # the collision guard too, and approving both silently overwrites the + # first-approved page via the PAGE update_page fallback in approve(). + 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..92829a05 100644 --- a/tests/test_compile.py +++ b/tests/test_compile.py @@ -317,6 +317,26 @@ def test_collision_with_pending_proposal_dropped( assert "pending review" in second.dropped[0]["reason"] +def test_collision_within_same_batch_second_draft_dropped( + store: KBStore, tmp_path: Path, +) -> None: + """two drafts in one LLM response with the same title must not both + survive validation — approving both would silently overwrite the first + page via the PAGE update_page fallback in approve().""" + 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: