From 433960a43e296c124acfb843f7f75d9eb8bbbfbd Mon Sep 17 00:00:00 2001 From: galuis116 Date: Wed, 15 Jul 2026 20:30:29 -0700 Subject: [PATCH] fix(compile): drop same-batch duplicate page titles before approval compile_kb() computed taken_names once before validating drafts and never folded accepted survivors back in, so two drafts in one llm response with the same (or same-slugifying) title both passed the collision guard. proposals.approve() exempts PAGE proposals from the generic existing-artifact guard to support the vault-edit flow, so approving the second duplicate silently routed through update_page and overwrote the first-approved page with no error and no reviewer signal. fold each survivor's title/slug into taken_names as soon as it's accepted, mirroring what phase 2 already does for wikilink resolution. new regression files two same-titled drafts in one batch, approves the surviving proposal, and asserts the first page's body is untouched. Fixes #439 --- CHANGELOG.md | 6 ++++++ src/vouch/compile.py | 6 ++++++ tests/test_compile.py | 20 ++++++++++++++++++++ 3 files changed, 32 insertions(+) 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: