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
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,7 @@ All notable changes to vouch are documented here. Format follows
KB under `eval/fixture-kb/`, and an `eval` workflow gating retrieval changes
(#226).
### Fixed
- `Claim.text`, `Entity.name`, and `Page.title` now reject empty / whitespace-only values at the model layer via `@field_validator`s, mirroring the `Claim.evidence` min-citation validator (#81/#82). The non-empty contract previously lived only in the `propose_*` helpers, so `store.put_*`, `store.update_*`, and bundle/sync import all silently accepted blank-content artifacts; enforcing on the model closes every write path at once (fixes #155).
- `parse_since` (the `--since` parser behind `vouch metrics`/`vouch audit`) now raises a clean `MetricsError` for a duration too large to represent (e.g. `--since 1000000000000d`), instead of letting an uncaught `OverflowError` traceback escape — restoring the documented "clean error, not a traceback" contract.
- `sync_apply` now loads the sync source exactly once and passes the same `_SyncSource` instance into `sync_check`, closing a TOCTOU window where a bundle replaced on disk between the two `_load_source` calls could cause the validation and write phases to operate on different snapshots. Also eliminates redundant directory walks (KB sources) and triple tarball opens (bundle sources). Fixes #217.
- `vault_to_kb` now passes `slug_hint=page_id` to `propose_page` so vault edit proposals target the existing page id from frontmatter instead of a slugified copy of the title (fixes #219).
Expand Down
36 changes: 36 additions & 0 deletions src/vouch/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,18 @@ def _coerce_artifact_scope(value: object) -> object:
return value


def _require_non_empty(v: str, label: str) -> str:
"""Reject empty / whitespace-only required text fields (#155).

Shared by the ``Claim.text`` / ``Entity.name`` / ``Page.title``
validators. Gates on emptiness only — non-blank values pass through
unchanged, so surrounding whitespace is preserved.
"""
if not v.strip():
raise ValueError(f"{label} must not be empty")
return v


class ArtifactScope(BaseModel):
"""Structured scope: visibility tier plus optional project/agent binding."""

Expand Down Expand Up @@ -231,6 +243,16 @@ def _at_least_one_citation(cls, v: list[str]) -> list[str]:
"(README §'Object model'; CONTRIBUTING §'Things we won't merge')"
)
return v

@field_validator("text")
@classmethod
def _text_non_empty(cls, v: str) -> str:
# Same shape as _at_least_one_citation: the non-empty contract lived
# only in proposals.propose_claim, so store.put_claim,
# store.update_claim, and bundle.import_apply via _validate_content
# accepted text="" / whitespace and landed a claim carrying zero
# semantic content. Enforce on the model to close all paths at once.
return _require_non_empty(v, "claim text")
entities: list[str] = Field(default_factory=list)
supersedes: list[str] = Field(default_factory=list)
superseded_by: str | None = None
Expand Down Expand Up @@ -266,6 +288,13 @@ class Entity(BaseModel):
created_at: datetime = Field(default_factory=utcnow)
updated_at: datetime = Field(default_factory=utcnow)

@field_validator("name")
@classmethod
def _name_non_empty(cls, v: str) -> str:
# See Claim._text_non_empty — the propose_entity check alone left
# store.put_entity and bundle import accepting name="" / whitespace.
return _require_non_empty(v, "entity name")


class Relation(BaseModel):
"""Typed edge between entities / claims / pages."""
Expand Down Expand Up @@ -314,6 +343,13 @@ def _normalize_type(cls, v: Any) -> str:
raise ValueError("page type must be a non-empty string")
return v.strip()

@field_validator("title")
@classmethod
def _title_non_empty(cls, v: str) -> str:
# See Claim._text_non_empty — the propose_page check alone left
# store.put_page and bundle import accepting title="" / whitespace.
return _require_non_empty(v, "page title")


# --- audit + sessions -----------------------------------------------------

Expand Down
67 changes: 67 additions & 0 deletions tests/test_storage.py
Original file line number Diff line number Diff line change
Expand Up @@ -196,6 +196,47 @@ def test_update_claim_rejects_empty_evidence(store: KBStore) -> None:
assert (store.kb_dir / "claims" / "c1.yaml").read_text() == persisted_before


@pytest.mark.parametrize("bad", ["", " ", "\n\t "])
def test_claim_model_rejects_empty_text(store: KBStore, bad: str) -> None:
"""Regression for #155: same shape as the #81 evidence validator, on the
text field. Empty / whitespace-only text is rejected at the model layer so
direct construction, store.put_claim, and bundle import all inherit it."""
src = store.put_source(b"e")
with pytest.raises(ValidationError, match="text must not be empty"):
Claim(id="c1", text=bad, evidence=[src.id])


def test_put_claim_rejects_empty_text(store: KBStore) -> None:
src = store.put_source(b"e")
with pytest.raises(ValidationError, match="text must not be empty"):
store.put_claim(Claim(id="c1", text=" ", evidence=[src.id]))
assert not (store.kb_dir / "claims" / "c1.yaml").exists()


def test_update_claim_rejects_empty_text(store: KBStore) -> None:
"""A previously-populated claim cannot be mutated down to blank text and
silently re-persisted — update_claim re-validates via model_validate."""
src = store.put_source(b"e")
store.put_claim(Claim(id="c1", text="cited", evidence=[src.id]))
persisted_before = (store.kb_dir / "claims" / "c1.yaml").read_text()

c = store.get_claim("c1")
c.text = " "

with pytest.raises(ValidationError, match="text must not be empty"):
store.update_claim(c)
assert (store.kb_dir / "claims" / "c1.yaml").read_text() == persisted_before


def test_claim_text_preserves_surrounding_whitespace_when_non_blank(
store: KBStore,
) -> None:
"""The validator gates on emptiness only — it must not strip content."""
src = store.put_source(b"e")
c = Claim(id="c1", text=" padded text ", evidence=[src.id])
assert c.text == " padded text "


# --- pages ----------------------------------------------------------------


Expand All @@ -217,6 +258,19 @@ def test_page_with_frontmatter_round_trip(store: KBStore) -> None:
assert back.type == PageType.CONCEPT


@pytest.mark.parametrize("bad", ["", " ", "\n"])
def test_page_model_rejects_empty_title(bad: str) -> None:
"""Regression for #155 — empty / whitespace titles rejected on the model."""
with pytest.raises(ValidationError, match="title must not be empty"):
Page(id="p1", title=bad)


def test_put_page_rejects_empty_title(store: KBStore) -> None:
with pytest.raises(ValidationError, match="title must not be empty"):
store.put_page(Page(id="p1", title=" "))
assert not (store.kb_dir / "pages" / "p1.md").exists()


# --- entities + relations -------------------------------------------------


Expand All @@ -226,6 +280,19 @@ def test_entity_round_trip(store: KBStore) -> None:
assert back.name == "Foo"


@pytest.mark.parametrize("bad", ["", " ", "\t\n"])
def test_entity_model_rejects_empty_name(bad: str) -> None:
"""Regression for #155 — empty / whitespace names rejected on the model."""
with pytest.raises(ValidationError, match="name must not be empty"):
Entity(id="e1", name=bad, type=EntityType.CONCEPT)


def test_put_entity_rejects_empty_name(store: KBStore) -> None:
with pytest.raises(ValidationError, match="name must not be empty"):
store.put_entity(Entity(id="e1", name=" ", type=EntityType.CONCEPT))
assert not (store.kb_dir / "entities" / "e1.yaml").exists()


def test_relation_round_trip(store: KBStore) -> None:
store.put_entity(Entity(id="a", name="A", type=EntityType.PROJECT))
store.put_entity(Entity(id="b", name="B", type=EntityType.PROJECT))
Expand Down