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.
What happened
compile_kb()buildstaken_namesonce before validating drafts, then neverupdates 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 (orsame-slugifying) title, both pass
_draft_problem()and get filed asseparate PAGE proposals whose
idis_slugify(title)— identical for both,since
propose_pagederivesidfrom the title when noslug_hintisgiven.
proposals.approve()deliberately exemptsProposalKind.PAGEfrom thegeneric "no existing artifact" guard, to support the vault-edit flow where a
proposal legitimately targets an existing 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.approveeventindistinguishable from a legitimate edit.
The collision-guard comment in
compile.pyshows this was a known invariantthe code intended to enforce, just not for this case:
taken_namesis computed once before the phase-1 loop and never grows assurvivors 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 shouldhave the second one dropped by
_draft_problem()("page for ... already exists or is pending review"), same as if it collided with a pre-existingpage 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 thesame internal validation path with two same-titled drafts directly (no LLM
needed to demonstrate the defect):
After approving both proposals in order,
store.get_page("deploy-workflow").bodyis
"SECOND BODY totally different content [claim: a-fact]"— the firstapproved 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 collisionsacross separate
compile_kb()calls; neither covers two colliding draftsreturned in the same LLM response.
Environment
vouch compile/kb.compile, independent of adapter host.vouch/stateNot required to reproduce — any initialized
.vouch/KB withcompile.llm_cmdconfigured (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 againstTAKEN TOPICS(existing pages / pending proposals), not against its ownoutput — 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 anequivalent 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_kbsets viaslug_hint) before allowing a PAGE proposal tofall through to
update_pageon an existing artifact, so a "new page"proposal can never silently overwrite one.