Skip to content

fix(models): require Claim.evidence to be non-empty at the model layer#82

Merged
plind-junior merged 4 commits into
vouchdev:testfrom
galuis116:fix/claim-model-requires-citation
May 27, 2026
Merged

fix(models): require Claim.evidence to be non-empty at the model layer#82
plind-junior merged 4 commits into
vouchdev:testfrom
galuis116:fix/claim-model-requires-citation

Conversation

@galuis116

@galuis116 galuis116 commented May 25, 2026

Copy link
Copy Markdown
Contributor

Problem

The Claim model has no min-evidence validator
(src/vouch/models.py:184-187), so a Claim with evidence: [] is
schema-valid. The README and CONTRIBUTING contract —

Claims must cite sources. A claim without at least one Source /
Evidence id is a validation error, not a warning.

is enforced in exactly one place, proposals.propose_claim
(src/vouch/proposals.py:89-90). Every other write path silently
accepts an uncited claim:

  • store.put_claim direct (src/vouch/storage.py:283-291) —
    the existence-check loop iterates zero times.
  • store.update_claim — writes the YAML without re-validating.
  • bundle.import_apply_validate_content defers to
    Claim.model_validate, which accepts evidence: []; the
    malicious bundle passes import_check (ok=True) and lands the
    uncited claim under the legitimate bundle_id, with a clean
    bundle.import audit event.

CONTRIBUTING explicitly lists this as a won't merge category:

Validation relaxations that let claims land without citations.
A claim without evidence is a working note at best — register
a source.

— except the enforcement was never on the model in the first
place, so every write path except the one explicit gate already
qualifies as the relaxed shape the team has stated they reject.

End-to-end repro on main (uncited_repro.py):

Bypass 1 (put_claim): SUCCEEDED — uncited claim on disk
  YAML on disk has 'evidence: []' → True
Bypass 2 (update_claim): SUCCEEDED — citations stripped to []

bundle.import_check(uncited): ok=True issues=[]
bundle.import_apply: wrote 1 files
landed claim evidence: []

BYPASS 3 CONFIRMED: bundle import landed an uncited claim — the
README contract was broken.

Fix

src/vouch/models.py, on the Claim model:

@field_validator("evidence")
@classmethod
def _at_least_one_citation(cls, v: list[str]) -> list[str]:
    if not v:
        raise ValueError("claim must cite at least one Source or Evidence id …")
    return v

That closes the direct-construction path, the bundle import path
(both go through Claim(...) / Claim.model_validate(...)), and
the propose_claim path (the existing string-comparison guard
becomes a redundant nicer-error message and is left in place).

The in-place mutation path (c.evidence = []; store.update_claim(c))
needs one extra line: store.update_claim now does
Claim.model_validate(claim.model_dump(mode="json")) before
persisting. Pydantic field validators only fire at construction
time, so mutation alone bypasses them; the round-trip catches it
before the YAML hits disk.

The existing user-facing guard in proposals.propose_claim is
left as a friendlier CLI/JSONL error message. No on-disk-layout,
schema, or bundle-format change — data the model never should
have accepted now raises.

Tests

Four regression tests:

  • test_claim_model_rejects_empty_evidence (tests/test_storage.py)
    Claim(evidence=[]) raises pydantic.ValidationError with
    message containing "cite at least one".
  • test_put_claim_rejects_empty_evidencestore.put_claim raises;
    no claims/<id>.yaml is written.
  • test_update_claim_rejects_empty_evidence — in-place mutation
    followed by store.update_claim raises; the on-disk YAML is
    byte-for-byte unchanged.
  • test_import_rejects_uncited_claim (tests/test_bundle.py) —
    a manifest-consistent bundle whose claim YAML has evidence: []
    is rejected by import_check (with a schema validation failed:
    issue), and import_apply raises RuntimeError("refusing to import: schema validation failed: ...") before writing.

All 4 pass on Windows (Python 3.12.13). Lint clean
(ruff check src tests). Full suite shows the same pre-existing
Windows-only failures present on main (O_NOFOLLOW in
storage.py, bundle round-trip CRLF) — both untouched by this PR.
Test count: 152 → 156 (+4 new, 0 regressions).

Audited every existing Claim(...) call site in tests/ before
adding the validator — none construct claims with empty evidence,
so the new gate breaks no existing test.

Fixes #81

Summary by CodeRabbit

  • Bug Fixes

    • Claims now require at least one citation on create and update; writes are validated before persisting to prevent uncited claims being saved.
    • Bundle import now rejects uncited claims instead of creating invalid files.
  • Documentation

    • Changelog updated with validation and migration/compatibility notes.
  • Tests

    • Added regression tests for uncited-claim rejection across write paths and bundle import.
  • Health

    • Lint/doctor reports unreadable or uncited legacy claim files as findings instead of crashing.

Review Change Stack

vouchdev#81)

The 'claims must cite sources' guarantee (README §'Why this exists'
point 3; CONTRIBUTING §'Things we won't merge') used to live only in
proposals.propose_claim, so every other write path silently accepted
Claim(evidence=[]) and landed an uncited claim:

  - store.put_claim direct: existence-check loop iterates zero times.
  - store.update_claim: writes the YAML without re-validating.
  - bundle.import_apply via _validate_content: defers to
    Claim.model_validate, which accepted evidence=[] because the
    model had no min-length constraint.

Add @field_validator('evidence') on Claim — raises ValueError when
the list is empty. Closes all three bypass paths in one place.

store.update_claim additionally re-validates via
Claim.model_validate(claim.model_dump()) before persisting, so
in-place mutation (c.evidence = []; store.update_claim(c)) raises
before the YAML hits disk — the field validator only fires at
construction time, not on attribute assignment.

Four regression tests:

  - test_claim_model_rejects_empty_evidence (tests/test_storage.py)
    — Claim(evidence=[]) raises pydantic.ValidationError.
  - test_put_claim_rejects_empty_evidence — store.put_claim raises;
    no claims/<id>.yaml is written.
  - test_update_claim_rejects_empty_evidence — in-place mutation
    + update_claim raises; the on-disk YAML is unchanged.
  - test_import_rejects_uncited_claim (tests/test_bundle.py) — a
    schema-valid bundle whose claim YAML has evidence: [] is
    rejected by import_check (schema validation issue) and
    import_apply raises before writing.

The existing guard in proposals.propose_claim becomes a redundant
user-facing error message and is left in place for the friendlier
CLI/JSONL error string. No on-disk-layout, schema, or
bundle-format change; data the model never should have accepted
now raises.
@coderabbitai

coderabbitai Bot commented May 25, 2026

Copy link
Copy Markdown

Important

Review skipped

Auto reviews are disabled on base/target branches other than the default branch.

Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: d274878d-9f9a-4b28-a46e-8c520adbd240

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review
📝 Walkthrough

Walkthrough

Move the "claims must have citations" invariant into the Claim Pydantic model, re-run validation on update, make lint resilient to legacy uncited claim YAMLs, and add tests plus a changelog entry documenting behavior and migration impact.

Changes

Claim evidence validation enforcement

Layer / File(s) Summary
Claim model evidence validator
src/vouch/models.py
Add @field_validator("evidence") raising ValueError when evidence is empty, enforcing at model construction.
Update-path re-validation
src/vouch/storage.py
Round-trip the in-memory Claim via Claim.model_validate(...) at start of update_claim() to re-run validators before persisting.
Storage validation tests
tests/test_storage.py
Import ValidationError and add three tests: model constructor rejection, put_claim rejection, and update_claim rejection ensuring no bad disk writes.
Bundle import validation test
tests/test_bundle.py
Add test_import_rejects_uncited_claim that builds a bundle with evidence: [] and asserts import_check/import_apply reject it and do not write the claim.
Health lint: imports and helpers
src/vouch/health.py
Import ValidationError and Claim, add _load_claims_for_lint(store) to load claims file-by-file and accumulate findings for parse/validation failures instead of raising.
Health lint: counts and report
src/vouch/health.py
Widen HealthReport.counts typing and compute report counts inline from preloaded claims and presence sets rather than calling status(store), returning HealthReport(ok, findings, counts).
Health regression test
tests/test_health.py
Add test that writes legacy uncited claim YAML and asserts health.lint reports invalid_claim without crashing and continues to process other claims.
Changelog entry
CHANGELOG.md
Document model-layer validator, update_claim re-validation, and lint/migration behavior for legacy uncited YAMLs.

Possibly related PRs

  • vouchdev/vouch#34: Adds schema validation during bundle import; the new Claim.evidence validator affects what bundle imports accept.

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~25 minutes

Poem

🐰
I hop through files with tiny paws so spry,
"Each claim must cite," I squeak beneath the sky.
Where empty evidence once slipped through the net,
Now lint shows broken leaves — no traceback fret.
Cite sources, friends — a carrot for the mind.

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 62.50% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title directly and clearly describes the main change: adding a validator to the Claim model to require non-empty evidence, which is the primary objective of the PR.
Linked Issues check ✅ Passed All four requirements from issue #81 are met: the model-layer validator is implemented, all three bypass paths are closed, the user-facing check is retained, and four regression tests are added.
Out of Scope Changes check ✅ Passed Changes to health.py and test_health.py represent necessary in-scope remediation for the validator affecting legacy KB reads, not out-of-scope additions.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Comment @coderabbitai help to get the list of available commands and usage tips.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

🧹 Nitpick comments (1)
src/vouch/storage.py (1)

321-327: ⚡ Quick win

Use the validated Claim instead of discarding it.

The re-validation on line 326 correctly catches invalid mutations, but the validated result is discarded. Line 327 still dumps the original claim object. For the current _at_least_one_citation validator this works fine (it only validates, doesn't transform), but if future validators transform data, those transformations would be lost.

♻️ Refactor to use the validated result
-        # Re-validate the in-memory Claim before persisting so model
-        # invariants (e.g. evidence must be non-empty — see `#81`) hold
-        # even when a caller mutated fields in place after get_claim().
-        # The Claim model's field validators only run at construction
-        # time; mutation alone bypasses them unless we round-trip.
-        Claim.model_validate(claim.model_dump(mode="json"))
-        self._claim_path(claim.id).write_text(_yaml_dump(claim.model_dump(mode="json")))
+        # Re-validate the in-memory Claim before persisting so model
+        # invariants (e.g. evidence must be non-empty — see `#81`) hold
+        # even when a caller mutated fields in place after get_claim().
+        # The Claim model's field validators only run at construction
+        # time; mutation alone bypasses them unless we round-trip.
+        validated = Claim.model_validate(claim.model_dump(mode="json"))
+        self._claim_path(claim.id).write_text(_yaml_dump(validated.model_dump(mode="json")))

This also eliminates the double serialization of the claim object.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/vouch/storage.py` around lines 321 - 327, The code currently calls
Claim.model_validate(claim.model_dump(...)) but discards the validated return
value and still writes the original claim; change this to capture the validated
model (e.g., validated = Claim.model_validate(claim.model_dump(mode="json")))
and then persist the validated model by calling
self._claim_path(claim.id).write_text(_yaml_dump(validated.model_dump(mode="json"))),
so any validator transformations are preserved and you avoid double-serializing
the original object.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@tests/test_bundle.py`:
- Around line 338-343: Tighten the assertions to verify the citation-specific
error about empty evidence rather than any schema failure: update the
diff.issues check to assert that at least one issue string contains both "schema
validation failed" and the citation invariant text (e.g., "cite at least one"),
and update the pytest.raises match to include both substrings (or a regex
matching "schema validation failed" and "cite at least one") when calling
bundle.import_apply; reference the existing call sites bundle.import_check,
diff.issues, and bundle.import_apply to locate the assertions to change.

In `@tests/test_storage.py`:
- Around line 122-129: The test currently constructs Claim(id="c1",
text="uncited", evidence=[]) which triggers model validation before
store.put_claim is called; change the test to construct a valid Claim (e.g.,
evidence with at least one item), then mutate that Claim's evidence to an empty
list and call store.put_claim(…) expecting ValidationError; keep the final
assertion that (store.kb_dir / "claims" / "c1.yaml").exists() is false and
reference the test function name test_put_claim_rejects_empty_evidence and the
store.put_claim and Claim symbols when making the change.

---

Nitpick comments:
In `@src/vouch/storage.py`:
- Around line 321-327: The code currently calls
Claim.model_validate(claim.model_dump(...)) but discards the validated return
value and still writes the original claim; change this to capture the validated
model (e.g., validated = Claim.model_validate(claim.model_dump(mode="json")))
and then persist the validated model by calling
self._claim_path(claim.id).write_text(_yaml_dump(validated.model_dump(mode="json"))),
so any validator transformations are preserved and you avoid double-serializing
the original object.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: d8ff8dfd-d140-40b8-9788-d75291902b43

📥 Commits

Reviewing files that changed from the base of the PR and between dd83000 and e142849.

📒 Files selected for processing (5)
  • CHANGELOG.md
  • src/vouch/models.py
  • src/vouch/storage.py
  • tests/test_bundle.py
  • tests/test_storage.py

Comment thread tests/test_bundle.py
Comment on lines +338 to +343
diff = bundle.import_check(store.kb_dir, bundle_path)
assert not diff.ok
assert any("schema validation failed" in i for i in diff.issues), diff.issues

with pytest.raises(RuntimeError, match="schema validation failed"):
bundle.import_apply(store.kb_dir, bundle_path)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Assert the citation-specific error, not only a generic schema failure.

The current checks can pass on unrelated schema errors. To keep this regression targeted to empty evidence, assert that the message also includes the citation invariant text (e.g., "cite at least one").

Suggested assertion tightening
     diff = bundle.import_check(store.kb_dir, bundle_path)
     assert not diff.ok
-    assert any("schema validation failed" in i for i in diff.issues), diff.issues
+    assert any(
+        "schema validation failed" in i and "cite at least one" in i
+        for i in diff.issues
+    ), diff.issues

-    with pytest.raises(RuntimeError, match="schema validation failed"):
+    with pytest.raises(RuntimeError, match=r"schema validation failed.*cite at least one"):
         bundle.import_apply(store.kb_dir, bundle_path)
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@tests/test_bundle.py` around lines 338 - 343, Tighten the assertions to
verify the citation-specific error about empty evidence rather than any schema
failure: update the diff.issues check to assert that at least one issue string
contains both "schema validation failed" and the citation invariant text (e.g.,
"cite at least one"), and update the pytest.raises match to include both
substrings (or a regex matching "schema validation failed" and "cite at least
one") when calling bundle.import_apply; reference the existing call sites
bundle.import_check, diff.issues, and bundle.import_apply to locate the
assertions to change.

Comment thread tests/test_storage.py
Comment on lines +122 to +129
def test_put_claim_rejects_empty_evidence(store: KBStore) -> None:
"""Regression for #81: store.put_claim is a direct write path
that used to silently accept Claim(evidence=[]) because the
only existence-check loop iterated zero times. The model-level
validator now fires before put_claim is even called."""
with pytest.raises(ValidationError, match="cite at least one"):
store.put_claim(Claim(id="c1", text="uncited", evidence=[]))
assert not (store.kb_dir / "claims" / "c1.yaml").exists()

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

This test can pass without ever exercising store.put_claim validation.

On Line 128, Claim(id="c1", text="uncited", evidence=[]) raises before store.put_claim(...) is called, so this does not verify the write-path behavior. Use a valid claim, mutate evidence to [], then call store.put_claim to catch mutation-based bypasses.

Proposed test adjustment
 def test_put_claim_rejects_empty_evidence(store: KBStore) -> None:
@@
-    with pytest.raises(ValidationError, match="cite at least one"):
-        store.put_claim(Claim(id="c1", text="uncited", evidence=[]))
+    src = store.put_source(b"e")
+    claim = Claim(id="c1", text="uncited", evidence=[src.id])
+    claim.evidence = []
+    with pytest.raises(ValidationError, match="cite at least one"):
+        store.put_claim(claim)
     assert not (store.kb_dir / "claims" / "c1.yaml").exists()
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
def test_put_claim_rejects_empty_evidence(store: KBStore) -> None:
"""Regression for #81: store.put_claim is a direct write path
that used to silently accept Claim(evidence=[]) because the
only existence-check loop iterated zero times. The model-level
validator now fires before put_claim is even called."""
with pytest.raises(ValidationError, match="cite at least one"):
store.put_claim(Claim(id="c1", text="uncited", evidence=[]))
assert not (store.kb_dir / "claims" / "c1.yaml").exists()
def test_put_claim_rejects_empty_evidence(store: KBStore) -> None:
"""Regression for `#81`: store.put_claim is a direct write path
that used to silently accept Claim(evidence=[]) because the
only existence-check loop iterated zero times. The model-level
validator now fires before put_claim is even called."""
src = store.put_source(b"e")
claim = Claim(id="c1", text="uncited", evidence=[src.id])
claim.evidence = []
with pytest.raises(ValidationError, match="cite at least one"):
store.put_claim(claim)
assert not (store.kb_dir / "claims" / "c1.yaml").exists()
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@tests/test_storage.py` around lines 122 - 129, The test currently constructs
Claim(id="c1", text="uncited", evidence=[]) which triggers model validation
before store.put_claim is called; change the test to construct a valid Claim
(e.g., evidence with at least one item), then mutate that Claim's evidence to an
empty list and call store.put_claim(…) expecting ValidationError; keep the final
assertion that (store.kb_dir / "claims" / "c1.yaml").exists() is false and
reference the test function name test_put_claim_rejects_empty_evidence and the
store.put_claim and Claim symbols when making the change.

@plind-junior

Copy link
Copy Markdown
Collaborator

My question before merging: since the validator now also fires when claims are read back, a KB that already has an uncited claim from the old bug would start throwing on get_claim/lint/doctor with no way to repair it — is that acceptable for pre-1.0, or should we add a small note (or a vouch doctor repair hint) for anyone who already slipped one through? Either way, the fix itself looks great. 🙏

…hdev#82 review)

The new Claim.evidence min-citation validator (vouchdev#81) also fires when
claims are read back from disk. A KB that has a pre-existing
uncited claims/<id>.yaml from before the fix would otherwise crash
vouch lint / vouch doctor with a bare pydantic.ValidationError
deep in store.list_claims().

Add _load_claims_for_lint(), a per-file iteration that catches
pydantic.ValidationError (and any other load error) and surfaces
each bad file as a Finding with code='invalid_claim' and an
explicit repair hint: 'edit the YAML to add a citation, or delete
the file'. lint() also stops calling status() to populate counts —
status() calls the strict store.list_claims() which would re-raise
on the same files — and builds the counts dict inline from the
safely-loaded valid claims.

Regression test in tests/test_health.py:
  - test_lint_surfaces_legacy_uncited_claim_yaml_without_crashing
    hand-crafts a claims/legacy.yaml with evidence: []  (matches
    the on-disk shape an older buggy write path would have left),
    asserts vouch lint runs to completion, surfaces invalid_claim
    in findings with the repair-hint message, and that the
    well-formed sibling claim is still discovered.

CHANGELOG migration note expanded to describe the repair hint.
@galuis116

Copy link
Copy Markdown
Contributor Author

Good catch — agreed that a bare pydantic.ValidationError deep in
store.list_claims() is a bad first experience for anyone with a
pre-existing uncited claim on disk, even pre-1.0. Went with the
"add a vouch doctor repair hint" option since the friendlier
diagnostic is cheap and the existing validator is still doing the
real work.

What changed in 5c881de:

src/vouch/health.py now has _load_claims_for_lint, which walks
claims/*.yaml one file at a time, catches pydantic.ValidationError
(and any other load error), and surfaces each bad file as a
Finding:

invalid_claim (severity: error)
  claim legacy (/.../claims/legacy.yaml) fails model validation:
  Value error, claim must cite at least one Source or Evidence id …
  — edit the YAML to add a citation, or delete the file

lint() also stops calling status() to populate counts — status()
itself goes through the strict store.list_claims() and would
re-raise on the same files. Counts are built inline from the
safely-loaded valid claims so the report is self-consistent.

The strict validator on the write paths is unchanged — every new
write still rejects evidence=[] at the model layer. The change is
purely on the read/diagnostic side: legacy on-disk uncited claims
get a clean repair list instead of a traceback. kb.context /
kb.search etc. still go through store.list_claims() directly,
so they'll still raise if a KB is genuinely broken — but the user's
first action (vouch lint or vouch doctor) now tells them
exactly which files to fix.

Regression test (tests/test_health.py):
test_lint_surfaces_legacy_uncited_claim_yaml_without_crashing
hand-crafts a claims/legacy.yaml with evidence: [] (the on-disk
shape an older buggy write path would have left), asserts
vouch lint runs to completion, surfaces an invalid_claim
finding with the repair-hint string, and that the well-formed
sibling claim still gets discovered.

CHANGELOG migration note updated to describe the repair hint.

Full suite: ruff clean, 5/5 tests/test_health.py pass, 157
tests pass overall (was 156, +1 new). Same 4 pre-existing
Windows-only failures on main are untouched.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@src/vouch/health.py`:
- Around line 143-160: The counts dict mixes non-int types causing type errors:
remove or move "kb_dir" out of HealthReport.counts and ensure every value in
counts is an int (e.g., replace "index_present": (store.kb_dir /
index_db.DB_FILENAME).exists() with "index_present": int((store.kb_dir /
index_db.DB_FILENAME).exists()) and convert any other non-int entries
similarly); update construction near the end of the function that builds counts
and return HealthReport(ok=ok, findings=findings, counts=counts) (or add a
separate field on HealthReport for kb_dir if you need to keep it).
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 415ab105-dd99-42ba-b9fb-86063ead19fa

📥 Commits

Reviewing files that changed from the base of the PR and between e142849 and 5c881de.

📒 Files selected for processing (3)
  • CHANGELOG.md
  • src/vouch/health.py
  • tests/test_health.py
✅ Files skipped from review due to trivial changes (1)
  • CHANGELOG.md

Comment thread src/vouch/health.py
The new inline-built counts in lint() (from 5c881de) is a literal
dict with mixed value types (str/int/bool), which mypy correctly
inferred as dict[str, object] and rejected against the
HealthReport.counts: dict[str, int] annotation. The original
status() returned the same mixed dict via an untyped 'dict' return,
which masked the mismatch — the narrow type was effectively never
checked at the call site.

Widen counts to dict[str, Any] to match runtime reality. Also
tighten status()'s return annotation from 'dict' to 'dict[str, Any]'
for consistency. No caller does arithmetic on counts values; they
just echo or pass through, so the widening is risk-free.
@plind-junior

plind-junior commented May 26, 2026

Copy link
Copy Markdown
Collaborator

Create PR against the test branch

@galuis116 galuis116 changed the base branch from main to test May 26, 2026 14:40
@galuis116

Copy link
Copy Markdown
Contributor Author

@plind-junior — retargeted to test. Base is now vouchdev:test; the 3 commits, CodeRabbit review thread, reply about the lint repair hint, and CI history all stay on this same PR.

@plind-junior

Copy link
Copy Markdown
Collaborator

Fix the conflict, other than that LGTM!

@galuis116

Copy link
Copy Markdown
Contributor Author

Fix the conflict, other than that LGTM!

Conflict resolved.

@plind-junior plind-junior merged commit cdd6fe6 into vouchdev:test May 27, 2026
6 checks passed
jsdevninja pushed a commit to jsdevninja/vouch that referenced this pull request May 28, 2026
…hdev#82 review)

The new Claim.evidence min-citation validator (vouchdev#81) also fires when
claims are read back from disk. A KB that has a pre-existing
uncited claims/<id>.yaml from before the fix would otherwise crash
vouch lint / vouch doctor with a bare pydantic.ValidationError
deep in store.list_claims().

Add _load_claims_for_lint(), a per-file iteration that catches
pydantic.ValidationError (and any other load error) and surfaces
each bad file as a Finding with code='invalid_claim' and an
explicit repair hint: 'edit the YAML to add a citation, or delete
the file'. lint() also stops calling status() to populate counts —
status() calls the strict store.list_claims() which would re-raise
on the same files — and builds the counts dict inline from the
safely-loaded valid claims.

Regression test in tests/test_health.py:
  - test_lint_surfaces_legacy_uncited_claim_yaml_without_crashing
    hand-crafts a claims/legacy.yaml with evidence: []  (matches
    the on-disk shape an older buggy write path would have left),
    asserts vouch lint runs to completion, surfaces invalid_claim
    in findings with the repair-hint message, and that the
    well-formed sibling claim is still discovered.

CHANGELOG migration note expanded to describe the repair hint.
@coderabbitai coderabbitai Bot mentioned this pull request May 29, 2026
3 tasks
@philluiz2323 philluiz2323 mentioned this pull request Jun 2, 2026
3 tasks
galuis116 added a commit to galuis116/vouch that referenced this pull request Jun 16, 2026
Claim's four graph-reference fields — entities, supersedes, superseded_by,
contradicts — were validated by no write path. put_claim checked only
claim.evidence; update_claim re-validated the model (no KB access, so no
ref check); bundle.import_apply writes claim YAML straight to disk. The
vouchdev#124 graph-integrity fix closed Relation.source/target/evidence and
Page.entities/sources but, as its own storage.py comment shows, skipped
the Claim's own reference fields — even though fsck already declares
dangling_supersedes / dangling_superseded_by / dangling_contradicts as
error-severity findings. The invariant was articulated but enforced by
no writer (same shape as vouchdev#81 / vouchdev#123).

- storage.KBStore._validate_claim_refs: entities -> entity ids,
  supersedes/contradicts/superseded_by -> claim ids. Called from both
  put_claim and update_claim (the latter closes the in-place-mutation
  reach path, mirroring the vouchdev#82 re-validation fix).
- bundle.import_check: extend the claim branch of the graph-integrity
  pass with the same checks, matching the existing page-ref checks, so a
  bundle can't land a dangling claim ref through import_apply's direct
  write.
- Honest lifecycle writes are unaffected: supersede/contradict load both
  ends via get_claim before linking, so their refs always resolve.

Regression tests in test_storage.py, test_bundle.py, and test_health.py;
the fsck/CLI dangling-chain tests now write poisoned YAML directly to disk
to reproduce the legacy on-disk state fsck must still surface.

Closes vouchdev#196.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

bug: Claim model has no min-evidence validator — uncited claims land via bundle import, put_claim, update_claim

2 participants