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

    Fields

    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions