Skip to content

bug: compile_kb same-batch duplicate page titles bypass the collision guard, causing a silent overwrite on approval #439

Description

@philluiz2323

What happened

compile_kb() builds taken_names once before validating drafts, then never
updates it as drafts are accepted within the same batch. When the LLM output
for a single compile_kb() call contains two drafts with the same (or
same-slugifying) title, both pass _draft_problem() and get filed as
separate PAGE proposals whose id is _slugify(title) — identical for both,
since propose_page derives id from the title when no slug_hint is
given.

proposals.approve() deliberately exempts ProposalKind.PAGE from the
generic "no existing artifact" guard, to support the vault-edit flow where a
proposal legitimately targets an existing page:

# proposals.py
if proposal.kind != ProposalKind.PAGE:
    _ensure_no_existing_artifact(store, proposal.kind, payload["id"])
...
elif proposal.kind == ProposalKind.PAGE:
    page = Page(**payload)
    ...
    try:
        store.get_page(page.id)
        store.update_page(page)
    except ArtifactNotFoundError:
        store.put_page(page)

So approving the first duplicate proposal creates the page; approving the
second — which the reviewer sees as a distinct, separately-queued proposal —
finds the page already exists and silently routes through update_page(),
overwriting it. No error, no diff, no "already exists" signal to the
reviewer. The audit log records a normal proposal.page.approve event
indistinguishable from a legitimate edit.

The collision-guard comment in compile.py shows this was a known invariant
the code intended to enforce, just not for this case:

# compile.py
# collision guard: approve() routes an existing page id through
# update_page (the vault-edit path), so a colliding "new" draft would
# silently overwrite the page on approval. drop it here instead;
# compiled updates are a future feature, not an accident.
if title.lower() in taken_names or _slugify(title) in taken_names:
    return f"page for {title!r} already exists or is pending review"

taken_names is computed once before the phase-1 loop and never grows as
survivors are accepted — unlike phase 2 (wikilink resolution) just below it,
which does fold survivors back in incrementally
(known = known_static | {t.lower() for _, t in survivors}).

What you expected

Two drafts in the same compile_kb() batch with colliding titles should
have the second one dropped by _draft_problem() ("page for ... already exists or is pending review"), same as if it collided with a pre-existing
page or an older pending proposal. Approving both proposals should never
result in one silently destroying the other's content.

Reproduction

compile_kb() reads from an LLM, so the most deterministic repro calls the
same internal validation path with two same-titled drafts directly (no LLM
needed to demonstrate the defect):

from vouch import storage, proposals
from vouch.compile import _draft_problem

store = storage.Store(kb_dir)  # any initialized .vouch/ dir

drafts = [
    {"title": "Deploy Workflow", "body": "first body [claim: a-fact]"},
    {"title": "Deploy Workflow", "body": "SECOND BODY totally different content [claim: a-fact]"},
]

existing = store.list_pages()
taken_names = {p.title.strip().lower() for p in existing}
taken_names |= {p.id.strip().lower() for p in existing}

# both drafts pass — taken_names never sees draft #1 before draft #2 is checked
for d in drafts:
    assert _draft_problem(store, d, taken_names=taken_names) is None

# ... both get filed as separate PAGE proposals with id="deploy-workflow" ...
# approving proposal 1 -> put_page (creates the page, body "first body ...")
# approving proposal 2 -> get_page succeeds -> update_page (OVERWRITES with "SECOND BODY ...")

After approving both proposals in order, store.get_page("deploy-workflow").body
is "SECOND BODY totally different content [claim: a-fact]" — the first
approved page is gone, with no error raised anywhere in the approval path.

The two existing tests in tests/test_compile.py
(test_collision_with_existing_page_dropped,
test_collision_with_pending_proposal_dropped) only exercise collisions
across separate compile_kb() calls; neither covers two colliding drafts
returned in the same LLM response.

Environment

.vouch/ state

Not required to reproduce — any initialized .vouch/ KB with compile.llm_cmd
configured (or the internal repro above, which bypasses the LLM call
entirely) demonstrates the defect.

Anything else

compile_kb's own prompt only instructs the LLM to dedupe against
TAKEN TOPICS (existing pages / pending proposals), not against its own
output — so an LLM producing two overlapping/duplicate topic drafts in one
structured response is a plausible, not contrived, trigger.

Suggested fix: fold each survivor's title/slug into taken_names (or an
equivalent running set) as soon as it's accepted in the phase-1 loop,
mirroring what phase 2 already does for wikilinks. As defense in depth,
proposals.approve() could require an explicit "this is an edit" marker
(as vault_to_kb sets via slug_hint) before allowing a PAGE proposal to
fall through to update_page on an existing artifact, so a "new page"
proposal can never silently overwrite one.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions