diff --git a/CHANGELOG.md b/CHANGELOG.md index c01bfea3..676ad4ba 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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, diff --git a/src/vouch/lifecycle.py b/src/vouch/lifecycle.py index 735076e2..30592a35 100644 --- a/src/vouch/lifecycle.py +++ b/src/vouch/lifecycle.py @@ -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, *, @@ -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] = [] @@ -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) @@ -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, }, ) diff --git a/tests/test_lifecycle.py b/tests/test_lifecycle.py new file mode 100644 index 00000000..dd87c9e3 --- /dev/null +++ b/tests/test_lifecycle.py @@ -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"] diff --git a/tests/test_web.py b/tests/test_web.py index 0b76b954..46cfab87 100644 --- a/tests/test_web.py +++ b/tests/test_web.py @@ -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: