diff --git a/CHANGELOG.md b/CHANGELOG.md index d405b36e..1d60ff66 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/src/vouch/proposals.py b/src/vouch/proposals.py index ef90cbad..131d9ddb 100644 --- a/src/vouch/proposals.py +++ b/src/vouch/proposals.py @@ -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, @@ -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 @@ -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, @@ -346,7 +366,7 @@ 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, @@ -354,6 +374,16 @@ def propose_relation( "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, diff --git a/tests/test_proposals.py b/tests/test_proposals.py index 1665b718..5b9f02a9 100644 --- a/tests/test_proposals.py +++ b/tests/test_proposals.py @@ -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 @@ -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() == []