Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,13 @@ All notable changes to vouch are documented here. Format follows
in config.yaml (#476).

### Fixed
- `propose-claim`, `propose-relation`, and `propose-entity` now validate
the payload against the Claim/Relation/Entity model at propose time
instead of only at approve. an out-of-range `--confidence` or an
invalid entity/relation type used to file a proposal that could never
pass `approve()`, sitting stuck in the pending queue with no clear way
to fix it; it is now rejected immediately with the same error message
approve would have raised.
- approve/reject/expire record the audit event *before* moving the
proposal to decided/. a crash between the two used to leave a durable
decision with no authoritative history; it now leaves a pending
Expand Down
36 changes: 33 additions & 3 deletions src/vouch/proposals.py
Original file line number Diff line number Diff line change
Expand Up @@ -131,7 +131,7 @@ def propose_claim(
raise ProposalError(f"unknown source/evidence id: {eid}") from e
claim_id = slug_hint or _slugify(text)
claim_text = text.strip()
payload = {
payload: dict[str, Any] = {
"id": claim_id,
"text": claim_text,
"type": claim_type,
Expand All @@ -140,6 +140,16 @@ def propose_claim(
"entities": entities or [],
"tags": tags or [],
}
# Validate against the Claim model itself (same check approve()'s
# Claim(**payload) construction and the batch precheck in
# _payload_block_reason both already perform) so an out-of-range
# confidence or other model-level constraint violation is rejected here,
# at propose time, rather than filing a proposal that can never pass
# approve() and sits stuck in the pending queue until someone notices.
try:
Claim(**payload)
except (ValidationError, TypeError) as e:
raise ProposalError(f"invalid claim payload: {e}") from e
exclude_claim: str | None = None
if (store.kb_dir / "claims" / f"{claim_id}.yaml").exists():
exclude_claim = claim_id
Expand Down Expand Up @@ -291,13 +301,23 @@ def propose_entity(
) -> Proposal:
if not name.strip():
raise ProposalError("entity name is empty")
payload = {
payload: dict[str, Any] = {
"id": slug_hint or _slugify(name),
"name": name.strip(),
"type": entity_type,
"aliases": aliases or [],
"description": description,
}
# Validate against the Entity model itself (same check approve()'s
# Entity(**payload) construction and the batch precheck in
# _payload_block_reason both already perform) so an invalid entity type
# is rejected here, at propose time, rather than filing a proposal that
# can never pass approve() and sits stuck in the pending queue until
# someone notices.
try:
Entity(**payload)
except (ValidationError, TypeError) as e:
raise ProposalError(f"invalid entity payload: {e}") from e
return _file_proposal(
store, kind=ProposalKind.ENTITY, payload=payload,
proposed_by=proposed_by, session_id=session_id,
Expand Down Expand Up @@ -346,14 +366,24 @@ def propose_relation(
f"unknown source/evidence id: {eid}"
) from e
rid = f"{src}--{relation}--{target}"
payload = {
payload: dict[str, Any] = {
"id": _slugify(rid),
"source": src,
"relation": relation,
"target": target,
"confidence": confidence,
"evidence": evidence or [],
}
# Validate against the Relation model itself (same check approve()'s
# Relation(**payload) construction and the batch precheck in
# _payload_block_reason both already perform) so an out-of-range
# confidence or an invalid relation type is rejected here, at propose
# time, rather than filing a proposal that can never pass approve() and
# sits stuck in the pending queue until someone notices.
try:
Relation(**payload)
except (ValidationError, TypeError) as e:
raise ProposalError(f"invalid relation payload: {e}") from e
return _file_proposal(
store, kind=ProposalKind.RELATION, payload=payload,
proposed_by=proposed_by, session_id=session_id,
Expand Down
72 changes: 71 additions & 1 deletion tests/test_proposals.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,13 @@

import pytest

from vouch.proposals import propose_quoted_claim
from vouch.proposals import (
ProposalError,
propose_claim,
propose_entity,
propose_quoted_claim,
propose_relation,
)
from vouch.receipts import ReceiptStatus, verify_evidence
from vouch.storage import KBStore

Expand Down Expand Up @@ -60,3 +66,67 @@ def test_propose_quoted_claim_is_idempotent_on_repeated_span(store: KBStore) ->
assert first is not None and second is not None
# the span was stored once; both claims cite the same evidence
assert len(store.list_evidence()) == 1


# propose-time model validation: an out-of-range confidence (or other
# model-level constraint violation) must be rejected at propose time, not
# filed as a proposal that can never pass approve() and sits stuck in the
# pending queue. Regression for the gap where propose_claim/propose_relation/
# propose_entity built their payload dict and handed it straight to
# _file_proposal with no check against the Claim/Relation/Entity model's own
# Field(ge=0.0, le=1.0) / enum constraints -- approve() (via Claim(**payload)
# etc.) was the only place that ever caught it, too late to give the
# proposer a usable error and too late to keep the pending queue clean.


def test_propose_claim_rejects_out_of_range_confidence(store: KBStore) -> None:
src = store.put_source(b"evidence text", title="t")
with pytest.raises(ProposalError, match="invalid claim payload"):
propose_claim(
store,
text="a claim with an impossible confidence",
evidence=[src.id],
proposed_by="tester",
confidence=1.5,
)
# nothing was filed -- the pending queue stays clean
assert store.list_proposals() == []


def test_propose_claim_rejects_negative_confidence(store: KBStore) -> None:
src = store.put_source(b"evidence text", title="t")
with pytest.raises(ProposalError, match="invalid claim payload"):
propose_claim(
store,
text="a claim with a negative confidence",
evidence=[src.id],
proposed_by="tester",
confidence=-0.5,
)
assert store.list_proposals() == []


def test_propose_relation_rejects_out_of_range_confidence(store: KBStore) -> None:
a = store.put_source(b"endpoint a", title="a")
b = store.put_source(b"endpoint b", title="b")
with pytest.raises(ProposalError, match="invalid relation payload"):
propose_relation(
store,
src=a.id,
relation="references",
target=b.id,
proposed_by="tester",
confidence=2.0,
)
assert store.list_proposals() == []


def test_propose_entity_rejects_invalid_type(store: KBStore) -> None:
with pytest.raises(ProposalError, match="invalid entity payload"):
propose_entity(
store,
name="a thing",
entity_type="not-a-real-entity-type",
proposed_by="tester",
)
assert store.list_proposals() == []
Loading