Skip to content

fix(health): vouch fsck crashes with KeyError on any approved delete proposal #682

Description

@philluiz2323

What happened

vouch fsck crashes with an uncaught KeyError on any KB that has ever
had an approved delete proposal.

_check_decided_proposals in src/vouch/health.py builds a presence
dict keyed by ProposalKind, then indexes it by every approved
proposal's own kind:

# src/vouch/health.py:479-497 (before fix)
presence: dict[ProposalKind, set[str]] = {
    ProposalKind.CLAIM: set(claims),
    ProposalKind.PAGE: set(pages),
    ProposalKind.ENTITY: set(entities),
    ProposalKind.RELATION: relations,
}
for pr in store.list_proposals(ProposalStatus.APPROVED):
    artifact_id = pr.payload.get("id") if isinstance(pr.payload, dict) else None
    ...
    if artifact_id not in presence[pr.kind]:

presence has no entry for ProposalKind.DELETE. But propose_delete()
(src/vouch/proposals.py) files delete proposals with
kind=ProposalKind.DELETE, and approve() explicitly allows a DELETE
proposal to be approved without requiring the target artifact to
pre-exist (it's being removed, not created). Any approved delete
proposal reaching this loop hits presence[ProposalKind.DELETE]
KeyError, aborting fsck() entirely.

What you expected

vouch fsck should handle DELETE proposals correctly: verify the
target artifact is actually absent (checked against target_kind, the
kind of what was deleted — not pr.kind, which is always DELETE), and
report a Finding if it's invalid or wasn't actually removed, rather than
crashing.

Reproduction

import tempfile, pathlib
from vouch.storage import KBStore
from vouch import proposals, health

d = pathlib.Path(tempfile.mkdtemp())
store = KBStore.init(d)
src = store.put_source(b"evidence", title="s")
pr = proposals.propose_claim(store, text="the sky is blue", evidence=[src.id], proposed_by="agent-a")
claim = proposals.approve(store, pr.id, approved_by="human-b")

del_pr = proposals.propose_delete(store, target_kind="claim", target_id=claim.id, proposed_by="agent-a")
proposals.approve(store, del_pr.id, approved_by="human-c")

health.fsck(store)  # raises

Output:

Traceback (most recent call last):
  File ".../health.py", line 369, in fsck
    _check_decided_proposals(store, claims, pages, entities, findings)
  File ".../health.py", line 497, in _check_decided_proposals
    if artifact_id not in presence[pr.kind]:
KeyError: <ProposalKind.DELETE: 'delete'>

vouch fsck's CLI entry (src/vouch/cli.py) calls health.fsck() with
no exception handling around it, so the CLI command itself crashes.

tests/test_health.py has zero coverage of delete proposals in
_check_decided_proposals, which is why this went unnoticed.

Environment

  • vouch version: test branch @ current HEAD
  • Python version: 3.11+
  • OS: any
  • Host: any — vouch fsck / kb.fsck (if exposed) is host-independent

.vouch/ state

Not required to reproduce — the repro above uses a fresh temp KB.

Anything else

This exact defect and a fix were previously submitted as #538
(fix(health): fsck survives approved delete proposals,
CodeRabbit-reviewed, author addressed review feedback) but the PR was
closed unmerged on 2026-07-29 purely for going stale against a
fast-moving test branch (a CHANGELOG.md conflict) — the maintainer's
closing comment explicitly said "this isn't a judgement on the change
itself... happy to look again." Re-verified independently against
current test HEAD: the crash is still live.

Suggested fix: handle ProposalKind.DELETE proposals in a first pass —
resolve target_kind from the payload, report
decided_delete_invalid_target_kind if it's missing/unrecognized, report
decided_delete_artifact_present if the target still exists on disk, and
otherwise record the id as legitimately deleted. A second pass then
checks ordinary create/edit proposals against presence, skipping any
artifact id that a delete proposal legitimately removed — otherwise the
original creating proposal (still recorded as APPROVED in decided/)
false-positives as decided_missing_artifact once its artifact is
correctly deleted.

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