Skip to content
Merged
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
9 changes: 9 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -122,6 +122,15 @@ All notable changes to vouch are documented here. Format follows
per-prompt block, the session banner, `vouch status` and the opt-in
question all say so rather than calling it "this repo's" knowledge.

### Fixed
- `clear` reads a naive `before` as utc instead of raising. a date-only
cutoff — `2026-07-01`, the shape the cli help, the console's own error
text, and the `kb.clear` docs all advertise — parses naive, and comparing
it against a claim's aware `created_at` raised `TypeError`: a traceback
from `vouch claims-clear --before`, an error response over mcp/jsonl, and
an unhandled 500 on the review console's `/clear-claims`. normalised at
the `lifecycle.clear_claims` chokepoint, so all four surfaces are fixed
at once; the audit event records the normalised cutoff.
### Added
- **ingest selection knob (`vouch ingest --max-claims / --budget-chars`).**
capture used to file every substantive sentence of a source — complete,
Expand Down
15 changes: 12 additions & 3 deletions src/vouch/lifecycle.py
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,12 @@ class LifecycleError(RuntimeError):
pass


def _utc(dt: datetime) -> datetime:
if dt.tzinfo is None:
return dt.replace(tzinfo=UTC)
return dt.astimezone(UTC)


def supersede(
store: KBStore,
*,
Expand Down Expand Up @@ -180,13 +186,16 @@ def clear_claims(
store: Knowledge base store
auto_only: If True, only clear auto-approved claims (auto_approved=True).
If False, clear all claims matching date filter.
before: If set, only clear claims created before this datetime.
before: If set, only clear claims created before this datetime. A naive
value is read as UTC — `--before 2026-07-01` is the documented
shape and parses naive, while `created_at` is always aware.
actor: Who is performing the operation.
dry_run: If True, don't write changes, just return what would be cleared.

Returns:
List of claims that were (or would be) archived.
"""
cutoff = _utc(before) if before is not None else None
all_claims = store.list_claims()
to_clear: list[Claim] = []

Expand All @@ -200,7 +209,7 @@ def clear_claims(
continue

# Filter by date range
if before and claim.created_at >= before:
if cutoff and _utc(claim.created_at) >= cutoff:
continue

to_clear.append(claim)
Expand All @@ -222,7 +231,7 @@ def clear_claims(
data={
"count": len(to_clear),
"auto_only": auto_only,
"before": before.isoformat() if before else None,
"before": cutoff.isoformat() if cutoff else None,
},
)

Expand Down
81 changes: 81 additions & 0 deletions tests/test_lifecycle.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
"""Lifecycle ops that don't need the embedding stack.

`tests/test_clear_claims.py` covers the same feature but skips without numpy,
so the timezone regression lives here where the base CI job runs it.
"""

from __future__ import annotations

from datetime import UTC, datetime, timedelta
from pathlib import Path

import pytest

from vouch import audit
from vouch import lifecycle as life
from vouch.proposals import approve, propose_claim
from vouch.storage import KBStore


@pytest.fixture
def store(tmp_path: Path) -> KBStore:
s = KBStore.init(tmp_path)
s.config_path.write_text(
"review:\n approver_role: trusted-agent\n", encoding="utf-8",
)
return s


def test_clear_claims_reads_a_naive_before_as_utc(store: KBStore) -> None:
"""A naive `before` filters as UTC instead of aborting the clear.

`--before 2026-07-01` — the shape the CLI, the console error text, and the
kb.clear docs all advertise — parses to a naive datetime, while a claim's
`created_at` is always aware. Comparing the two raised TypeError, which
surfaced as a traceback on the CLI and a 500 in the review console.
"""
src = store.put_source(b"the sky is blue on a clear day")
now = datetime.now(UTC)

old_pr = propose_claim(
store, text="old claim", evidence=[src.id], proposed_by="agent"
)
old_claim = store.get_claim(approve(store, old_pr.id, approved_by="agent").id)
old_claim.created_at = now - timedelta(days=2)
store.update_claim(old_claim)

new_pr = propose_claim(
store, text="new claim", evidence=[src.id], proposed_by="agent"
)
approve(store, new_pr.id, approved_by="agent")

naive_cutoff = (now - timedelta(days=1)).replace(tzinfo=None)
cleared = life.clear_claims(
store, auto_only=True, before=naive_cutoff, actor="user", dry_run=False
)

assert [c.text for c in cleared] == ["old claim"]
assert store.get_claim(cleared[0].id).status.value == "archived"

event = next(
e for e in audit.read_events(store.kb_dir) if e.event == "claim.bulk_clear"
)
assert event.data["before"] == naive_cutoff.replace(tzinfo=UTC).isoformat()


def test_clear_claims_aware_before_is_unchanged(store: KBStore) -> None:
"""Normalising the cutoff leaves an already-aware `before` alone."""
src = store.put_source(b"the sky is blue on a clear day")
now = datetime.now(UTC)

pr = propose_claim(store, text="old claim", evidence=[src.id], proposed_by="agent")
claim = store.get_claim(approve(store, pr.id, approved_by="agent").id)
claim.created_at = now - timedelta(days=2)
store.update_claim(claim)

cutoff = now - timedelta(days=1)
cleared = life.clear_claims(
store, auto_only=True, before=cutoff, actor="user", dry_run=True
)

assert [c.text for c in cleared] == ["old claim"]
21 changes: 21 additions & 0 deletions tests/test_web.py
Original file line number Diff line number Diff line change
Expand Up @@ -446,6 +446,27 @@ def test_clear_apply_archives_auto_preserves_human_and_audits(
assert auto_id in events[0].object_ids


def test_clear_date_only_before_does_not_500(
client: TestClient, store: KBStore,
) -> None:
"""`before=2026-07-01` is the shape the view's own error text advertises.

It parses to a naive datetime; comparing it against aware `created_at`
used to raise TypeError out of both handlers instead of filtering.
"""
auto_id = _auto_approved_claim(store, "auto cruft with a date filter")

r = client.get("/clear-claims?before=2999-01-01")
assert r.status_code == 200
assert auto_id in r.text

r = client.post(
"/clear-claims", data={"before": "2999-01-01"}, follow_redirects=False,
)
assert r.status_code == 303
assert store.get_claim(auto_id).status.value == "archived"


def test_clear_invalid_before_shows_inline_error(
client: TestClient, store: KBStore,
) -> None:
Expand Down
Loading