Skip to content

fix(health): fsck survives approved delete and goal proposals - #735

Open
galuis116 wants to merge 8 commits into
vouchdev:testfrom
galuis116:fix/fsck-approved-delete-proposals
Open

fix(health): fsck survives approved delete and goal proposals#735
galuis116 wants to merge 8 commits into
vouchdev:testfrom
galuis116:fix/fsck-approved-delete-proposals

Conversation

@galuis116

Copy link
Copy Markdown
Contributor

Supersedes #683, closed for review feedback — same branch, extended per
the reviewer's request.

What changed

_check_decided_proposals in src/vouch/health.py handles
ProposalKind.DELETE proposals in a dedicated first pass instead of
indexing a presence dict that has no entry for that kind, and presence
is now exhaustive over every ProposalKind that owns an artifact of its
own kind — including GOAL, added per review on #683.

Why

presence was keyed on CLAIM/PAGE/ENTITY/RELATION only, so
_check_decided_proposals raised KeyError on any approved DELETE
proposal (presence[pr.kind], since DELETE isn't in presence either).
Delete proposals now get their own first pass checked against
target_kind (reporting decided_delete_invalid_target_kind for a
missing/unrecognized one, or decided_delete_artifact_present if the
target wasn't actually removed), with the ids they legitimately removed
excluded from the second pass so the original creating proposal doesn't
false-positive as decided_missing_artifact once its artifact is
correctly gone.

Review on #683 caught that this was one kind short: an approved GOAL
proposal hits the identical KeyError two lines earlier, at
deleted[pr.kind], since deleted shares presence's same four keys.
Verified against test HEAD:

pr = proposals.propose_goal(store, title="ship the thing", proposed_by="agent")
proposals.approve(store, pr.id, approved_by="reviewer")
health.fsck(store)  # KeyError: <ProposalKind.GOAL: 'goal'>

Goals are fully live (proposals.py files and approves them, lifecycle
maps them to get_goal), so vouch fsck crashes today on any KB with an
approved goal, exactly as it did on an approved delete.

Rather than adding a GOAL line next to DELETE and leaving the same gap
open for a seventh kind, presence is now tied to ProposalKind itself:
an assert derives the expected key set as frozenset(ProposalKind) - {DELETE} (DELETE is checked separately against target_kind, not its
own presence entry) rather than hand-copying members, so a future kind
fails the next test that touches fsck instead of crashing for a user —
the same exhaustiveness shape test_capabilities_matches_jsonl_handlers
already uses for method/handler parity.

Fixes #682

What might break

Nothing for users with an existing .vouch/ directory — no on-disk shape,
kb.* method, or object model change. _check_decided_proposals gains a
goals parameter (internal, not a public signature) threaded from
fsck's existing store.list_goals() call.

VEP

Not applicable — no object model, kb.* method, on-disk layout, bundle
format, or audit-log shape change. A crash fix + exhaustiveness guard
inside fsck's internals.

Tests

  • Local make check-equivalent: ruff clean (src + tests); mypy
    clean on health.py; all tests/test_health.py cases pass (29
    total: 25 pre-existing + 1 for the delete-proposal fix + 2 new for
    the goal fix + exhaustiveness pin, plus the earlier
    decided_no_artifact_id DELETE-payload test)
  • New / changed behaviour has a test —
    test_fsck_survives_approved_goal_proposal (confirmed it raises
    KeyError on the pre-fix code, passes with the fix) and
    test_check_decided_proposals_presence_covers_every_non_delete_kind
    (pins the exhaustiveness shape)
  • CHANGELOG.md updated under ## [Unreleased]

galuis116 and others added 8 commits July 30, 2026 13:44
_check_decided_proposals built a presence dict keyed by every approved
proposal's own kind, but propose_delete() files proposals with
kind=ProposalKind.DELETE and approve() explicitly allows a DELETE
proposal to clear without its target pre-existing (it's being removed,
not created). presence had no DELETE entry, so any KB that had ever
had a delete approved crashed fsck() with an uncaught KeyError -
vouch fsck's CLI entry has no exception handling around the call, so
the command itself crashed.

check delete proposals in a first pass against their target_kind (the
kind of what was deleted, not pr.kind which is always DELETE):
report decided_delete_invalid_target_kind for a missing or unrecognized
target_kind, decided_delete_artifact_present if the target somehow
still exists on disk, and otherwise record the id as legitimately
deleted. the second pass (ordinary create/edit proposals) skips any
artifact id a delete proposal legitimately removed - without that, the
*original* creating proposal, still recorded APPROVED in decided/,
false-positives as decided_missing_artifact the moment its artifact is
correctly deleted.

this exact defect and fix were previously submitted as vouchdev#538
(CodeRabbit-reviewed, author addressed feedback), but that PR was
closed unmerged for going stale against a fast-moving test branch, not
for anything wrong with the change; the maintainer's closing comment
explicitly invited a fresh PR.

Fixes vouchdev#682
…-coverage gap

the same missing-artifact-id check the second pass already applies to
create/edit proposals was silently skipped for DELETE proposals -
report decided_no_artifact_id for a malformed delete proposal too,
instead of passing it through with no finding. new test covers the
branch the repo's diff-coverage gate flagged as untested.
a6c6862 (vouchdev#686) fixed capture.load_config's min_observations and
dedup_window_seconds to fall back to their defaults on a malformed
config value via the new coerce_numeric() helper, instead of raising
ValueError straight out of load_config. 47eaf56 (vouchdev#645, realtime
opt-in) branched off the pre-fix capture.py and reintroduced the bare
int()/float() calls when it merged into test - the coerce_numeric
import survived (nothing else referenced it), but the two call sites
it fed didn't, silently reverting the fix and leaving
test_load_config_malformed_numeric_falls_back red on `test` HEAD
itself, currently failing this PR's CI via ruff's unused-import gate.

restore the coerce_numeric() calls, matching recall.load_config's
still-intact equivalent.

unrelated to this PR's own change (fsck delete-proposal handling);
needed only to get CI green on top of a currently-broken `test`.
review caught that the delete-proposal fix was one kind short: an
approved GOAL proposal hits the identical KeyError two lines earlier,
at deleted[pr.kind], since presence only covered CLAIM/PAGE/ENTITY/
RELATION. goals are fully live (proposals.py files and approves them,
lifecycle maps them to get_goal), so vouch fsck crashes today on any
kb with an approved goal, exactly as it did on an approved delete.

add ProposalKind.GOAL to presence, threaded through from store.
list_goals() in fsck's caller. tie the map to the enum itself rather
than hand-copying members: an assert derives the expected key set as
frozenset(ProposalKind) - {DELETE} (DELETE is checked separately
against target_kind, not its own presence entry), so a future seventh
kind fails the next test that touches fsck instead of crashing for a
user the day it lands - the same exhaustiveness shape
test_capabilities_matches_jsonl_handlers already uses for method/
handler parity.

new regression test confirms an approved goal proposal survives fsck
without crashing, and a pinning test confirms presence's expected key
set still matches ProposalKind minus DELETE.

Fixes vouchdev#682
@github-actions github-actions Bot added docs documentation, specs, examples, and repo guidance tests tests and fixtures size: S 50-199 changed non-doc lines labels Jul 31, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

docs documentation, specs, examples, and repo guidance size: S 50-199 changed non-doc lines tests tests and fixtures

Projects

None yet

Development

Successfully merging this pull request may close these issues.

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

2 participants