From 0ed8a89fb3562922e7a99eaec5b7c8d9d6bf0326 Mon Sep 17 00:00:00 2001 From: galuis116 Date: Mon, 25 May 2026 04:28:13 -0700 Subject: [PATCH 001/149] fix(sessions): close crystallize review-gate bypass via summary page (#76) crystallize wrote a durable Page directly through store.put_page, embedding sess.task, sess.note, and sess.agent verbatim into the rendered markdown body. The body was never gated by propose_page + approve, so an agent calling kb.session_start(task=) and getting any one claim approved via crystallize could land arbitrary content into pages/. The page surfaces in kb.read_page, kb.list_pages, kb.context, and (once #60 is fixed) kb.search. Restrict the summary body to fields the proposing agent cannot influence: session id (server-generated), timestamps (server clock), and the list of approved artifact ids. The agent-controlled fields remain on the Session model itself and are still queryable, but no longer promoted into a durable Page. Also include summary_page_id in the session.crystallize audit event's object_ids when a page is written, so vouch audit truthfully attributes the write. Adds two regression tests: - test_crystallize_summary_page_does_not_leak_agent_controlled_fields - test_crystallize_audit_event_records_summary_page_id --- CHANGELOG.md | 11 ++++++++ src/vouch/sessions.py | 21 ++++++++++----- tests/test_sessions.py | 58 ++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 84 insertions(+), 6 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b36d069c..f8bbcab7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,6 +15,17 @@ All notable changes to vouch are documented here. Format follows the same tarball. `import_apply`, `import_check`, and `export_check` now validate every member path and raise on unsafe names. - Fix `vouch search` CLI: assign backend label per code path so substring fallback results are no longer mislabelled as `fts5`; update stale docstring to reflect multi-backend search surface (#52). +- Close the review-gate bypass in `sessions.crystallize` (#76). The + durable session-summary page wrote `sess.task`, `sess.note`, and + `sess.agent` verbatim into rendered markdown, letting an agent + land arbitrary content into `pages/` by calling + `kb.session_start(task=...)` and getting any one claim approved + via crystallize. The summary body now contains only fields the + proposing agent cannot influence (session id, server-clock + timestamps, list of approved artifact ids). The + `session.crystallize` audit event now also includes the summary + page id in `object_ids` when a page is written, so `vouch audit` + truthfully attributes the write. ## [0.0.1] — 2026-05-17 diff --git a/src/vouch/sessions.py b/src/vouch/sessions.py index 5b4d6354..1cde8430 100644 --- a/src/vouch/sessions.py +++ b/src/vouch/sessions.py @@ -109,9 +109,12 @@ def crystallize( store.put_page(page) summary_page_id = page.id + crystallize_object_ids = [sess.id, *approved_artifact_ids] + if summary_page_id is not None: + crystallize_object_ids.append(summary_page_id) audit.log_event( store.kb_dir, event="session.crystallize", actor=approver, - object_ids=[sess.id, *approved_artifact_ids], + object_ids=crystallize_object_ids, data={"approved": len(approved_artifact_ids), "failed": len(failures)}, ) return { @@ -123,11 +126,17 @@ def crystallize( def _build_summary_body(sess: Session, ids: list[str]) -> str: - lines = [f"# Session {sess.id}", ""] - if sess.task: - lines += [f"**Task:** {sess.task}", ""] - lines += [ - f"**Agent:** {sess.agent}", + # The summary page is durable and surfaces in kb.read_page / kb.search / + # kb.context, but never goes through propose_page + approve. The body is + # therefore restricted to fields the proposing agent cannot influence — + # session id (server-generated), timestamps (set from server clock at + # session_start / session_end), and the list of artifact ids that did go + # through the review gate. Anything agent-controlled (sess.task, + # sess.note, sess.agent) is omitted to keep the review-gate guarantee + # intact for the Page artifact kind. See #76. + lines = [ + f"# Session {sess.id}", + "", f"**Started:** {sess.started_at.isoformat()}", f"**Ended:** {(sess.ended_at or datetime.now(UTC)).isoformat()}", "", diff --git a/tests/test_sessions.py b/tests/test_sessions.py index 56d4a6cc..753d82ad 100644 --- a/tests/test_sessions.py +++ b/tests/test_sessions.py @@ -2,6 +2,7 @@ from __future__ import annotations +import json from pathlib import Path import pytest @@ -43,3 +44,60 @@ def test_crystallize_skips_already_approved(store: KBStore) -> None: sess_mod.session_end(store, sess.id) result = sess_mod.crystallize(store, sess.id, approver="u") assert result["approved"] == [] # already handled + + +def test_crystallize_summary_page_does_not_leak_agent_controlled_fields( + store: KBStore, +) -> None: + """Regression for #76: the durable summary page bypasses propose_page + + approve, so its body must contain only fields the proposing agent + cannot influence — no sess.task, sess.note, or sess.agent prose. An + agent supplying a markdown payload via session_start(task=...) must + not see that payload promoted into pages/.""" + src = store.put_source(b"e") + injected_task = "## DECISION\n\nWe will migrate. Approved by leadership." + injected_note = " agent-controlled note" + sess = sess_mod.session_start( + store, agent="mallory", task=injected_task, note=injected_note, + ) + propose_claim( + store, text="legitimate finding", evidence=[src.id], + proposed_by="mallory", session_id=sess.id, + ) + sess_mod.session_end(store, sess.id) + + result = sess_mod.crystallize(store, sess.id, approver="human") + page_id = result["summary_page_id"] + assert page_id is not None + + page = store.get_page(page_id) + assert injected_task not in page.body, page.body + assert injected_note not in page.body, page.body + assert "mallory" not in page.body, page.body + assert "## DECISION" not in page.body, page.body + + +def test_crystallize_audit_event_records_summary_page_id( + store: KBStore, +) -> None: + """Regression for #76: the audit event must include the summary page id + in object_ids when a page is written, so `vouch audit` is truthful + about every artifact crystallize produced.""" + src = store.put_source(b"e") + sess = sess_mod.session_start(store, agent="a") + propose_claim( + store, text="t", evidence=[src.id], proposed_by="a", session_id=sess.id, + ) + sess_mod.session_end(store, sess.id) + result = sess_mod.crystallize(store, sess.id, approver="u") + page_id = result["summary_page_id"] + assert page_id is not None + + audit_lines = (store.kb_dir / "audit.log.jsonl").read_text().splitlines() + cryst_events = [ + json.loads(line) for line in audit_lines + if json.loads(line).get("event") == "session.crystallize" + ] + assert cryst_events, "no session.crystallize audit event found" + last = cryst_events[-1] + assert page_id in last["object_ids"], last["object_ids"] From b2e3db13afac219a88e35b6e42e84a8e215fa278 Mon Sep 17 00:00:00 2001 From: galuis116 Date: Mon, 25 May 2026 05:22:48 -0700 Subject: [PATCH 002/149] fix(tests): remove unused unittest.mock.patch import in test_sessions Leftover from the merge of main (which brought PR #62's test_sessions.py changes); ruff F401 was failing CI on PR #77. --- tests/test_sessions.py | 1 - 1 file changed, 1 deletion(-) diff --git a/tests/test_sessions.py b/tests/test_sessions.py index 1a33b946..753d82ad 100644 --- a/tests/test_sessions.py +++ b/tests/test_sessions.py @@ -4,7 +4,6 @@ import json from pathlib import Path -from unittest.mock import patch import pytest From 0708c743ea2aacea1e820c8be60ceac0fd563dc7 Mon Sep 17 00:00:00 2001 From: galuis116 Date: Mon, 25 May 2026 06:29:18 -0700 Subject: [PATCH 003/149] fix(context,storage): exclude retracted claims from kb.context and reindex FTS5 on update (#78) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two compounding bugs let archived / superseded / redacted claims keep flowing back to agents through kb.context: 1. build_context_pack had no claim.status filter, so any retrieval hit was appended to the pack regardless of subsequent lifecycle mutations. 2. store.update_claim refreshed the embedding cache but never re-indexed the FTS5 row, so claims_fts.status stayed frozen at first-index time. Every lifecycle op (archive, supersede, contradict, confirm) routes through update_claim and was therefore invisible to FTS5 / semantic search. This made ClaimStatus.{ARCHIVED, SUPERSEDED, REDACTED} purely decorative on the read side — agents kept quoting retracted knowledge as if it were live. Fix: - storage.update_claim now also calls index_db.index_claim under a short-lived sqlite connection (mirroring proposals.approve's first-index pattern). FTS5 errors are logged-and-skipped so a lifecycle op never fails because the embedding/FTS5 layer is misconfigured. - context.build_context_pack resolves each claim hit, drops it if the on-disk status is in {ARCHIVED, SUPERSEDED, REDACTED}, and drops it if the claim YAML has been deleted between index time and now. CONTESTED claims keep surfacing so contradictions remain visible. Tests: - test_context_pack_excludes_archived_claims - test_context_pack_excludes_superseded_claims - test_update_claim_refreshes_fts5_status (asserts the FTS5 status column via direct SQL against state.db) No on-disk-layout, schema, or bundle-format change. --- CHANGELOG.md | 12 +++++++++ src/vouch/context.py | 35 ++++++++++++++++++------- src/vouch/storage.py | 19 ++++++++++++++ tests/test_context.py | 61 +++++++++++++++++++++++++++++++++++++++++++ 4 files changed, 117 insertions(+), 10 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c62e92e5..3ce8a543 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -23,6 +23,18 @@ All notable changes to vouch are documented here. Format follows a silent no-op. Existing Linux/macOS bundles are unchanged (their paths were already POSIX); Windows bundles produced before this fix should be re-exported. +- `kb.context` no longer returns claims whose status is `archived`, + `superseded`, or `redacted` (#78). Two compounding bugs were combining + to leak retracted knowledge back to agents: `build_context_pack` had + no status filter, and `store.update_claim` only refreshed the + embedding cache without keeping `claims_fts.status` in sync — so even + after `lifecycle.archive` / `supersede` / `contradict`, the FTS5 row + kept its first-index status and `kb.search` / `kb.context` matched the + retracted claim. `update_claim` now re-indexes the FTS5 row (mirroring + what `proposals.approve` does on first index), and + `build_context_pack` drops retracted claims from the assembled pack. + `CONTESTED` claims continue to surface so contradictions remain + visible. ## [0.0.1] — 2026-05-17 diff --git a/src/vouch/context.py b/src/vouch/context.py index 667fb518..931b99aa 100644 --- a/src/vouch/context.py +++ b/src/vouch/context.py @@ -17,9 +17,21 @@ from typing import Any, Literal, cast from . import index_db -from .models import ContextItem, ContextPack, ContextQuality +from .models import ClaimStatus, ContextItem, ContextPack, ContextQuality from .storage import ArtifactNotFoundError, KBStore +# Claim statuses that have been explicitly retracted from active circulation. +# Any retrieval surface that hands knowledge back to an agent must exclude +# these — otherwise the archive/supersede/redact controls are decorative. +# CONTESTED is intentionally not in this set: contested claims are still +# part of the conversation, just disputed; lint / context callers can +# decide what to do with them. +_RETRACTED_CLAIM_STATUSES = frozenset({ + ClaimStatus.ARCHIVED, + ClaimStatus.SUPERSEDED, + ClaimStatus.REDACTED, +}) + ContextItemKind = Literal["claim", "page", "entity", "relation", "source"] @@ -46,14 +58,6 @@ def _retrieve(store: KBStore, query: str, limit: int ] -def _citations_for_claim(store: KBStore, claim_id: str) -> list[str]: - try: - claim = store.get_claim(claim_id) - except ArtifactNotFoundError: - return [] - return list(claim.evidence) - - def _enrich_summary(store: KBStore, kind: str, artifact_id: str, summary: str) -> str: """Return a non-empty summary, falling back to the stored artifact text.""" if summary: @@ -89,7 +93,18 @@ def build_context_pack( for kind, hid, summary, score, backend in hits: cites: list[str] = [] if kind == "claim": - cites = _citations_for_claim(store, hid) + # Exclude retracted claims even if the underlying index still + # matches them (the FTS5 row's status column can lag — see #78 + # and the companion update_claim reindex). A missing claim is + # also treated as retracted: the YAML may have been deleted + # while the index row survived. + try: + claim = store.get_claim(hid) + except ArtifactNotFoundError: + continue + if claim.status in _RETRACTED_CLAIM_STATUSES: + continue + cites = list(claim.evidence) summary = _enrich_summary(store, kind, hid, summary) items.append( ContextItem( diff --git a/src/vouch/storage.py b/src/vouch/storage.py index a8205054..12d7c477 100644 --- a/src/vouch/storage.py +++ b/src/vouch/storage.py @@ -27,6 +27,7 @@ import logging import os import re +import sqlite3 import stat from pathlib import Path from typing import Any @@ -310,6 +311,24 @@ def update_claim(self, claim: Claim) -> Claim: raise ArtifactNotFoundError(f"claim {claim.id}") self._claim_path(claim.id).write_text(_yaml_dump(claim.model_dump(mode="json"))) self._embed_and_store(kind="claim", id=claim.id, text=claim.text) + # Keep the FTS5 row in sync with the on-disk claim so lifecycle + # mutations (archive, supersede, contradict, confirm) are reflected + # in retrieval immediately. Without this, claims_fts.status stays + # frozen at first-index time and retracted claims keep matching + # kb.search / kb.context. + from . import index_db as _index_db + + try: + with _index_db.open_db(self.kb_dir) as conn: + _index_db.index_claim( + conn, id=claim.id, text=claim.text, + type=claim.type.value, status=claim.status.value, + tags=list(claim.tags), + ) + except sqlite3.Error as e: + _embed_log.warning( + "claim %s: FTS5 reindex skipped on update (%s)", claim.id, e, + ) return claim # --- pages ------------------------------------------------------------- diff --git a/tests/test_context.py b/tests/test_context.py index 338d7301..0c155665 100644 --- a/tests/test_context.py +++ b/tests/test_context.py @@ -80,6 +80,67 @@ def test_build_context_pack_uses_semantic_default(tmp_path: Path) -> None: assert any(item["id"] == "c1" for item in pack.get("items", [])) +def test_context_pack_excludes_archived_claims(store: KBStore) -> None: + """Regression for #78: build_context_pack must skip claims with status + in {ARCHIVED, SUPERSEDED, REDACTED} — without the filter, retracted + knowledge keeps flowing back to agents and the lifecycle controls + are decorative.""" + from vouch import lifecycle + + src = store.put_source(b"e") + store.put_claim(Claim(id="c1", text="mongodb is faster than postgres", + evidence=[src.id])) + health.rebuild_index(store) + pack = context.build_context_pack(store, query="mongodb", limit=5) + assert any(it["id"] == "c1" for it in pack["items"]), pack + + lifecycle.archive(store, claim_id="c1", actor="reviewer") + pack = context.build_context_pack(store, query="mongodb", limit=5) + assert not any(it["id"] == "c1" for it in pack["items"]), pack + + +def test_context_pack_excludes_superseded_claims(store: KBStore) -> None: + """Regression for #78: supersede(old, new) must keep `new` retrievable + while removing `old` from kb.context.""" + from vouch import lifecycle + + src = store.put_source(b"e") + store.put_claim(Claim(id="old", text="redis caching strategy v1", + evidence=[src.id])) + store.put_claim(Claim(id="new", text="redis caching strategy v2", + evidence=[src.id])) + health.rebuild_index(store) + lifecycle.supersede(store, old_claim_id="old", new_claim_id="new", + actor="reviewer") + + pack = context.build_context_pack(store, query="redis caching", limit=5) + ids = {it["id"] for it in pack["items"]} + assert "new" in ids, pack + assert "old" not in ids, pack + + +def test_update_claim_refreshes_fts5_status(store: KBStore) -> None: + """Regression for #78: store.update_claim must keep claims_fts.status + in sync, otherwise the context filter above (and any other status- + aware retrieval) sees a stale value from first-index time.""" + import sqlite3 + + from vouch import lifecycle + + src = store.put_source(b"e") + store.put_claim(Claim(id="c1", text="redis caching strategy", + evidence=[src.id])) + health.rebuild_index(store) + lifecycle.archive(store, claim_id="c1", actor="reviewer") + + with sqlite3.connect(store.kb_dir / "state.db") as conn: + row = conn.execute( + "SELECT status FROM claims_fts WHERE id = ?", ("c1",), + ).fetchone() + assert row is not None + assert row[0] == "archived", row + + def test_build_context_pack_explain_flag_returns_score_breakdown( tmp_path: Path, ) -> None: From e1428494d61e21178edc748f6cdcab1143413e64 Mon Sep 17 00:00:00 2001 From: galuis116 Date: Mon, 25 May 2026 09:03:06 -0700 Subject: [PATCH 004/149] fix(models): require Claim.evidence to be non-empty at the model layer (#81) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The 'claims must cite sources' guarantee (README §'Why this exists' point 3; CONTRIBUTING §'Things we won't merge') used to live only in proposals.propose_claim, so every other write path silently accepted Claim(evidence=[]) and landed an uncited claim: - store.put_claim direct: existence-check loop iterates zero times. - store.update_claim: writes the YAML without re-validating. - bundle.import_apply via _validate_content: defers to Claim.model_validate, which accepted evidence=[] because the model had no min-length constraint. Add @field_validator('evidence') on Claim — raises ValueError when the list is empty. Closes all three bypass paths in one place. store.update_claim additionally re-validates via Claim.model_validate(claim.model_dump()) before persisting, so in-place mutation (c.evidence = []; store.update_claim(c)) raises before the YAML hits disk — the field validator only fires at construction time, not on attribute assignment. Four regression tests: - test_claim_model_rejects_empty_evidence (tests/test_storage.py) — Claim(evidence=[]) raises pydantic.ValidationError. - test_put_claim_rejects_empty_evidence — store.put_claim raises; no claims/.yaml is written. - test_update_claim_rejects_empty_evidence — in-place mutation + update_claim raises; the on-disk YAML is unchanged. - test_import_rejects_uncited_claim (tests/test_bundle.py) — a schema-valid bundle whose claim YAML has evidence: [] is rejected by import_check (schema validation issue) and import_apply raises before writing. The existing guard in proposals.propose_claim becomes a redundant user-facing error message and is left in place for the friendlier CLI/JSONL error string. No on-disk-layout, schema, or bundle-format change; data the model never should have accepted now raises. --- CHANGELOG.md | 12 ++++++++++++ src/vouch/models.py | 16 +++++++++++++++ src/vouch/storage.py | 6 ++++++ tests/test_bundle.py | 45 +++++++++++++++++++++++++++++++++++++++++++ tests/test_storage.py | 39 +++++++++++++++++++++++++++++++++++++ 5 files changed, 118 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index e570c8ff..488b0819 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -43,6 +43,18 @@ All notable changes to vouch are documented here. Format follows with between `import_check` and the apply re-open is rejected before anything reaches disk and the audit log does not record a `bundle.import` event. +- `Claim.evidence` now enforces "at least one citation" at the model + layer via a `@field_validator` (#81). Previously the + README-documented guarantee ("Claims must cite sources … a claim + without at least one Source/Evidence id is a validation error") + was enforced only in `proposals.propose_claim`, so every other + write path — direct `store.put_claim`, `store.update_claim`, and + `bundle.import_apply` via `_validate_content` — silently accepted + `evidence: []` and landed an uncited claim. The validator closes + all three paths at once; `store.update_claim` additionally + re-validates via `Claim.model_validate(...)` before persisting so + in-place mutation (`c.evidence = []; store.update_claim(c)`) + also raises before the YAML hits disk. ## [0.0.1] — 2026-05-17 diff --git a/src/vouch/models.py b/src/vouch/models.py index a4d1961b..1b340d85 100644 --- a/src/vouch/models.py +++ b/src/vouch/models.py @@ -185,6 +185,22 @@ class Claim(BaseModel): default_factory=list, description="Source ids OR Evidence ids — both are valid citations", ) + + @field_validator("evidence") + @classmethod + def _at_least_one_citation(cls, v: list[str]) -> list[str]: + # The "claims must cite sources" guarantee (README §"Why this exists" + # point 3; CONTRIBUTING §"Things we won't merge") used to live only + # in proposals.propose_claim, so every other write path — + # store.put_claim, store.update_claim, and bundle.import_apply via + # _validate_content — accepted evidence=[] and silently landed an + # uncited claim. Enforcing on the model closes all paths at once. + if not v: + raise ValueError( + "claim must cite at least one Source or Evidence id " + "(README §'Object model'; CONTRIBUTING §'Things we won't merge')" + ) + return v entities: list[str] = Field(default_factory=list) supersedes: list[str] = Field(default_factory=list) superseded_by: str | None = None diff --git a/src/vouch/storage.py b/src/vouch/storage.py index 559ec257..bcfa53fb 100644 --- a/src/vouch/storage.py +++ b/src/vouch/storage.py @@ -318,6 +318,12 @@ def list_claims(self) -> list[Claim]: def update_claim(self, claim: Claim) -> Claim: if not self._claim_path(claim.id).exists(): raise ArtifactNotFoundError(f"claim {claim.id}") + # Re-validate the in-memory Claim before persisting so model + # invariants (e.g. evidence must be non-empty — see #81) hold + # even when a caller mutated fields in place after get_claim(). + # The Claim model's field validators only run at construction + # time; mutation alone bypasses them unless we round-trip. + Claim.model_validate(claim.model_dump(mode="json")) self._claim_path(claim.id).write_text(_yaml_dump(claim.model_dump(mode="json"))) self._embed_and_store(kind="claim", id=claim.id, text=claim.text) return claim diff --git a/tests/test_bundle.py b/tests/test_bundle.py index 20e8d085..a851204c 100644 --- a/tests/test_bundle.py +++ b/tests/test_bundle.py @@ -299,6 +299,51 @@ def test_import_treats_missing_manifest_sha256_as_mismatch( assert not (store.kb_dir / "claims" / "c1.yaml").exists() +def test_import_rejects_uncited_claim(store: KBStore, tmp_path: Path) -> None: + """Regression for #81: a bundle whose claim YAML has evidence: [] + must be rejected by import_check / import_apply because the Claim + model now enforces the 'must cite at least one' invariant. Before + this fix, _validate_content deferred to pydantic, which accepted + evidence=[] and silently landed an uncited claim.""" + uncited_yaml = ( + b"id: bundle-uncited\n" + b'text: "shipped via bundle, no citations"\n' + b"type: fact\n" + b"status: stable\n" + b"confidence: 1.0\n" + b"evidence: []\n" + ) + bundle_path = tmp_path / "uncited.tar.gz" + manifest = { + "spec": bundle.SPEC_VERSION, + "bundle_id": "deadbeef", + "files": [{ + "path": "claims/bundle-uncited.yaml", + "size": len(uncited_yaml), + "sha256": hashlib.sha256(uncited_yaml).hexdigest(), + }], + "counts": {}, + "safety": {"has_proposed": False, "has_state_db": False, + "has_audit_log": False}, + } + with tarfile.open(bundle_path, "w:gz") as tar: + info = tarfile.TarInfo("claims/bundle-uncited.yaml") + info.size = len(uncited_yaml) + tar.addfile(info, io.BytesIO(uncited_yaml)) + mf_bytes = json.dumps(manifest).encode() + mf_info = tarfile.TarInfo(bundle.MANIFEST_NAME) + mf_info.size = len(mf_bytes) + tar.addfile(mf_info, io.BytesIO(mf_bytes)) + + diff = bundle.import_check(store.kb_dir, bundle_path) + assert not diff.ok + assert any("schema validation failed" in i for i in diff.issues), diff.issues + + with pytest.raises(RuntimeError, match="schema validation failed"): + bundle.import_apply(store.kb_dir, bundle_path) + assert not (store.kb_dir / "claims" / "bundle-uncited.yaml").exists() + + def test_import_check_passes_when_member_matches_manifest( store: KBStore, tmp_path: Path ) -> None: diff --git a/tests/test_storage.py b/tests/test_storage.py index 1f3dd8a2..46eeb73f 100644 --- a/tests/test_storage.py +++ b/tests/test_storage.py @@ -5,6 +5,7 @@ from pathlib import Path import pytest +from pydantic import ValidationError from vouch import audit, lifecycle from vouch.models import ( @@ -108,6 +109,44 @@ def test_claim_can_be_updated(store: KBStore) -> None: assert store.get_claim("c1").status == ClaimStatus.STABLE +def test_claim_model_rejects_empty_evidence() -> None: + """Regression for #81: the 'claims must cite sources' guarantee + (README §'Why this exists' point 3; CONTRIBUTING §'Things we + won't merge') is now enforced on the Claim model itself, so + every write path inherits the check instead of relying on + proposals.propose_claim alone.""" + with pytest.raises(ValidationError, match="cite at least one"): + Claim(id="c1", text="uncited", evidence=[]) + + +def test_put_claim_rejects_empty_evidence(store: KBStore) -> None: + """Regression for #81: store.put_claim is a direct write path + that used to silently accept Claim(evidence=[]) because the + only existence-check loop iterated zero times. The model-level + validator now fires before put_claim is even called.""" + with pytest.raises(ValidationError, match="cite at least one"): + store.put_claim(Claim(id="c1", text="uncited", evidence=[])) + assert not (store.kb_dir / "claims" / "c1.yaml").exists() + + +def test_update_claim_rejects_empty_evidence(store: KBStore) -> None: + """Regression for #81: a previously-cited claim cannot be mutated + down to evidence=[] and silently re-persisted. The model's field + validator only fires at construction time, so update_claim + re-validates via Claim.model_validate(claim.model_dump()) before + writing — otherwise in-place mutation would bypass the gate.""" + 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.evidence = [] # in-place mutation alone doesn't trigger validation + + with pytest.raises(ValidationError, match="cite at least one"): + store.update_claim(c) + assert (store.kb_dir / "claims" / "c1.yaml").read_text() == persisted_before + + # --- pages ---------------------------------------------------------------- From f558fd0fdc5fb071cca34f941be7ee522b03104f Mon Sep 17 00:00:00 2001 From: alpurkan17 Date: Mon, 25 May 2026 18:49:30 +0000 Subject: [PATCH 005/149] fix(bundle): reject import of bundles whose manifest lists files missing from tarball (#80) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit import_check previously only verified that tar members listed in the manifest had matching hashes, but never checked the reverse: whether every manifest entry had a corresponding tar member. A bundle whose manifest.json referenced claims/c1.yaml but whose tarball contained only manifest.json would pass import_check with ok=True, and import_apply would silently write nothing — no exception, no audit event indicating data loss. Add the missing-member pass (mirroring the existing check in export_check) so that manifest entries without a matching tar member produce a "manifest lists missing file" issue. import_apply then refuses to import because check.issues is non-empty. --- src/vouch/bundle.py | 66 +++++++++++++++++----------- tests/test_bundle.py | 100 ++++++++++++++++++++++++++++++++----------- 2 files changed, 118 insertions(+), 48 deletions(-) diff --git a/src/vouch/bundle.py b/src/vouch/bundle.py index a0595464..fa3be0a4 100644 --- a/src/vouch/bundle.py +++ b/src/vouch/bundle.py @@ -35,8 +35,14 @@ SPEC_VERSION = "vouch-bundle-0.1" EXPORT_SUBDIRS = ( - "claims", "pages", "sources", "entities", "relations", - "evidence", "sessions", "decided", + "claims", + "pages", + "sources", + "entities", + "relations", + "evidence", + "sessions", + "decided", ) VALIDATORS: dict[str, Any] = { @@ -72,14 +78,16 @@ def build_manifest(kb_dir: Path) -> dict[str, Any]: files: list[dict[str, Any]] = [] for rel, abs_path in _iter_export_files(kb_dir): data = abs_path.read_bytes() - files.append({ - # tarfile member names use POSIX `/` on every platform; the - # manifest path must match so set lookups and the per-subdir - # counter below work on Windows too. - "path": rel.as_posix(), - "size": len(data), - "sha256": sha256_hex(data), - }) + files.append( + { + # tarfile member names use POSIX `/` on every platform; the + # manifest path must match so set lookups and the per-subdir + # counter below work on Windows too. + "path": rel.as_posix(), + "size": len(data), + "sha256": sha256_hex(data), + } + ) # Bundle id is the sha256 of the sorted per-file hashes — same inputs # always produce the same id, so duplicate exports are recognisable. h = hashlib.sha256() @@ -90,8 +98,7 @@ def build_manifest(kb_dir: Path) -> dict[str, Any]: "bundle_id": h.hexdigest(), "files": files, "counts": { - sub: sum(1 for f in files if f["path"].startswith(f"{sub}/")) - for sub in EXPORT_SUBDIRS + sub: sum(1 for f in files if f["path"].startswith(f"{sub}/")) for sub in EXPORT_SUBDIRS }, "safety": { "has_proposed": False, @@ -112,7 +119,9 @@ def export(kb_dir: Path, *, dest: Path, actor: str = "vouch-export") -> dict[str info.size = len(manifest_bytes) tar.addfile(info, io.BytesIO(manifest_bytes)) audit.log_event( - kb_dir, event="bundle.export", actor=actor, + kb_dir, + event="bundle.export", + actor=actor, object_ids=[manifest["bundle_id"]], data={"dest": str(dest), "files": len(manifest["files"])}, ) @@ -180,8 +189,10 @@ def export_check(bundle_path: Path) -> ExportCheckResult: except KeyError: issues.append(f"manifest lists missing file: {path}") return ExportCheckResult( - ok=not issues, bundle_id=bundle_id, - files_checked=files_checked, issues=issues, + ok=not issues, + bundle_id=bundle_id, + files_checked=files_checked, + issues=issues, ) @@ -243,9 +254,7 @@ def import_check(kb_dir: Path, bundle_path: Path) -> ImportCheckResult: try: mf_member = tar.getmember(MANIFEST_NAME) except KeyError: - return ImportCheckResult( - False, "", [], [], [], ["bundle missing manifest.json"] - ) + return ImportCheckResult(False, "", [], [], [], ["bundle missing manifest.json"]) manifest = json.loads(tar.extractfile(mf_member).read().decode()) # type: ignore[union-attr] bundle_id = manifest.get("bundle_id", "") recorded = {f["path"]: f for f in manifest["files"]} @@ -278,11 +287,19 @@ def import_check(kb_dir: Path, bundle_path: Path) -> ImportCheckResult: issues.append(f"hash mismatch: {member.name}") continue _validate_content(member.name, body, issues) + for path in manifest_paths: + try: + tar.getmember(path) + except KeyError: + issues.append(f"manifest lists missing file: {path}") return ImportCheckResult( - ok=not issues, bundle_id=bundle_id, - new_files=new_files, conflicts=conflicts, - identical=identical, issues=issues, + ok=not issues, + bundle_id=bundle_id, + new_files=new_files, + conflicts=conflicts, + identical=identical, + issues=issues, ) @@ -337,8 +354,7 @@ def import_apply( # the audit-truthfulness anti-pattern #74 was about. if sha256_hex(body) != expected_sha: raise RuntimeError( - f"refusing to import: hash mismatch at write time: " - f"{member.name}" + f"refusing to import: hash mismatch at write time: {member.name}" ) val_issues: list[str] = [] _validate_content(member.name, body, val_issues) @@ -355,7 +371,9 @@ def import_apply( "on_conflict": on_conflict, } audit.log_event( - kb_dir, event="bundle.import", actor=actor, + kb_dir, + event="bundle.import", + actor=actor, object_ids=[check.bundle_id], data={ "written": len(written), diff --git a/tests/test_bundle.py b/tests/test_bundle.py index 20e8d085..6c82c5a5 100644 --- a/tests/test_bundle.py +++ b/tests/test_bundle.py @@ -144,15 +144,67 @@ def test_import_apply_rejects_absolute_path(store: KBStore, tmp_path: Path) -> N assert not target.exists() +def test_import_check_rejects_manifest_listing_missing_file(store: KBStore, tmp_path: Path) -> None: + """Manifest entries without a matching tar member must be flagged.""" + bundle_path = tmp_path / "missing.tar.gz" + manifest = { + "spec": bundle.SPEC_VERSION, + "bundle_id": "deadbeef", + "files": [ + { + "path": "claims/c1.yaml", + "size": 16, + "sha256": hashlib.sha256(b"text: any\n").hexdigest(), + }, + ], + "counts": {}, + "safety": {"has_proposed": False, "has_state_db": False, "has_audit_log": False}, + } + with tarfile.open(bundle_path, "w:gz") as tar: + mf_bytes = json.dumps(manifest).encode() + mf_info = tarfile.TarInfo(bundle.MANIFEST_NAME) + mf_info.size = len(mf_bytes) + tar.addfile(mf_info, io.BytesIO(mf_bytes)) + + result = bundle.import_check(store.kb_dir, bundle_path) + assert not result.ok + assert any("missing file" in i for i in result.issues) + + +def test_import_apply_rejects_bundle_with_missing_manifest_file( + store: KBStore, tmp_path: Path +) -> None: + """import_apply must refuse a bundle whose manifest lists a file absent from the tarball.""" + bundle_path = tmp_path / "missing.tar.gz" + manifest = { + "spec": bundle.SPEC_VERSION, + "bundle_id": "deadbeef", + "files": [ + { + "path": "claims/c1.yaml", + "size": 16, + "sha256": hashlib.sha256(b"text: any\n").hexdigest(), + }, + ], + "counts": {}, + "safety": {"has_proposed": False, "has_state_db": False, "has_audit_log": False}, + } + with tarfile.open(bundle_path, "w:gz") as tar: + mf_bytes = json.dumps(manifest).encode() + mf_info = tarfile.TarInfo(bundle.MANIFEST_NAME) + mf_info.size = len(mf_bytes) + tar.addfile(mf_info, io.BytesIO(mf_bytes)) + + with pytest.raises(RuntimeError, match="missing file"): + bundle.import_apply(store.kb_dir, bundle_path) + + def test_import_check_flags_path_traversal(store: KBStore, tmp_path: Path) -> None: bundle_path = tmp_path / "evil.tar.gz" _write_malicious_bundle(bundle_path, "../../evil.txt", b"pwned") result = bundle.import_check(store.kb_dir, bundle_path) assert not result.ok - assert any( - "traversal" in i or "unsafe" in i or "absolute path" in i - for i in result.issues - ) + assert any("traversal" in i or "unsafe" in i or "absolute path" in i for i in result.issues) def _write_hash_mismatched_bundle( @@ -188,9 +240,7 @@ def _write_hash_mismatched_bundle( tar.addfile(mf_info, io.BytesIO(mf_bytes)) -def test_import_rejects_member_with_mismatched_sha256( - store: KBStore, tmp_path: Path -) -> None: +def test_import_rejects_member_with_mismatched_sha256(store: KBStore, tmp_path: Path) -> None: """Regression for #74: a tar member whose body does not hash to the sha256 the manifest claims is a documented integrity violation — export_check flags it, so import_check and import_apply must too.""" @@ -208,17 +258,13 @@ def test_import_rejects_member_with_mismatched_sha256( assert not (store.kb_dir / "claims" / "c1.yaml").exists() -def test_import_rejects_source_content_mismatch( - store: KBStore, tmp_path: Path -) -> None: +def test_import_rejects_source_content_mismatch(store: KBStore, tmp_path: Path) -> None: """`_validate_content` skips `sources/*/content` files, so the manifest sha256 is the only thing that can detect substituted source bytes.""" legitimate = b"original source bytes" tampered = b"attacker-controlled bytes" bundle_path = tmp_path / "tampered.tar.gz" - _write_hash_mismatched_bundle( - bundle_path, "sources/deadbeef/content", legitimate, tampered - ) + _write_hash_mismatched_bundle(bundle_path, "sources/deadbeef/content", legitimate, tampered) diff = bundle.import_check(store.kb_dir, bundle_path) assert not diff.ok @@ -242,16 +288,24 @@ def test_import_apply_raises_on_write_time_hash_mismatch( tampered = b"text: TAMPERED\n" bundle_path = tmp_path / "tampered.tar.gz" _write_hash_mismatched_bundle( - bundle_path, "claims/c1.yaml", legitimate, tampered, + bundle_path, + "claims/c1.yaml", + legitimate, + tampered, ) # Force the pre-write check to look clean so the apply path reaches # the write-time re-verify branch. monkeypatch.setattr( - bundle, "import_check", + bundle, + "import_check", lambda *_a, **_k: bundle.ImportCheckResult( - ok=True, bundle_id="deadbeef", - new_files=["claims/c1.yaml"], conflicts=[], identical=[], issues=[], + ok=True, + bundle_id="deadbeef", + new_files=["claims/c1.yaml"], + conflicts=[], + identical=[], + issues=[], ), ) @@ -263,9 +317,7 @@ def test_import_apply_raises_on_write_time_hash_mismatch( assert "bundle.import" not in audit_text, audit_text -def test_import_treats_missing_manifest_sha256_as_mismatch( - store: KBStore, tmp_path: Path -) -> None: +def test_import_treats_missing_manifest_sha256_as_mismatch(store: KBStore, tmp_path: Path) -> None: """Regression for #74 review feedback: a hand-crafted manifest entry without a `sha256` field used to raise a bare KeyError in import_check and import_apply. Treat the missing field as a hash mismatch so the @@ -278,7 +330,9 @@ def test_import_treats_missing_manifest_sha256_as_mismatch( "files": [{"path": "claims/c1.yaml", "size": len(payload)}], "counts": {}, "safety": { - "has_proposed": False, "has_state_db": False, "has_audit_log": False, + "has_proposed": False, + "has_state_db": False, + "has_audit_log": False, }, } with tarfile.open(bundle_path, "w:gz") as tar: @@ -299,9 +353,7 @@ def test_import_treats_missing_manifest_sha256_as_mismatch( assert not (store.kb_dir / "claims" / "c1.yaml").exists() -def test_import_check_passes_when_member_matches_manifest( - store: KBStore, tmp_path: Path -) -> None: +def test_import_check_passes_when_member_matches_manifest(store: KBStore, tmp_path: Path) -> None: """The hash check is positive too: a member that matches manifest sha256 should not be reported as `hash mismatch`.""" payload = b"text: original\n" From 534cae6a68108824550736ec6676577171cd7d5b Mon Sep 17 00:00:00 2001 From: plind-junior <59729252+plind-junior@users.noreply.github.com> Date: Tue, 26 May 2026 06:19:51 +0900 Subject: [PATCH 006/149] docs: spec for vouch diff (claim/page revision diff) read-only `vouch diff ` that shows what changed between two claim or two page revisions: field-level changes plus a line-diff of the long text/body. auto-detects kind, hides churning metadata. --- .../specs/2026-05-25-vouch-diff-design.md | 95 +++++++++++++++++++ 1 file changed, 95 insertions(+) create mode 100644 docs/superpowers/specs/2026-05-25-vouch-diff-design.md diff --git a/docs/superpowers/specs/2026-05-25-vouch-diff-design.md b/docs/superpowers/specs/2026-05-25-vouch-diff-design.md new file mode 100644 index 00000000..f5bb4ea0 --- /dev/null +++ b/docs/superpowers/specs/2026-05-25-vouch-diff-design.md @@ -0,0 +1,95 @@ +# vouch diff — claim/page revision diff + +## Problem + +When a claim is superseded or a page is revised, there is no way to see *what +changed* between the old and new artifact. You can `show` each one and compare +by eye, but nothing renders the delta. ROADMAP 0.1 lists `vouch diff + ` for exactly this. + +## Goal + +`vouch diff ` shows what changed between two claim revisions +or two page revisions — field-level changes plus a line-diff of the long text +field. Read-only: no writes, no proposals, no audit events. + +## Decisions + +- **Auto-detect kind.** Resolve both ids as claims; if that fails, resolve both + as pages. Mismatched kinds or unknown ids are a clear error — no `--kind` + flag. +- **Semantic fields only.** Diff the fields that carry meaning; hide + always-churning metadata (`id`, `created_at`, `updated_at`, + `last_confirmed_at`, `approved_by`). +- **Line-diff the long text.** `claim.text` / `page.body` render as a + `difflib` unified diff; everything else as `field: old → new`. +- **CLI-only.** Read-only inspection; does not touch the `kb.*` capability set. + +## Components — `src/vouch/diff.py` + +### `DiffError(Exception)` +Raised for unknown ids and mismatched kinds. + +### `FieldChange` (dataclass) +`field: str, old, new` — one changed scalar/list field. + +### `ArtifactDiff` (dataclass) +`kind: str, old_id: str, new_id: str, changes: list[FieldChange], +text_diff: list[str]`. + +### `diff_artifacts(store, old_id, new_id) -> ArtifactDiff` +- **Kind resolution:** try `store.get_claim` on both ids → both succeed ⇒ + `kind="claim"`. Otherwise try `store.get_page` on both → `kind="page"`. If an + id resolves to neither, raise `DiffError("unknown artifact: ")`. If one is + a claim and the other a page, raise + `DiffError("cannot diff claim against page")`. +- **changes:** for each semantic field whose value differs, append a + `FieldChange`. The long text field is handled separately (not in `changes`). +- **text_diff:** `list(difflib.unified_diff(old_text.splitlines(), + new_text.splitlines(), lineterm=""))` for `claim.text` / `page.body`; empty + when unchanged. + +Field sets (long text field rendered as `text_diff`, the rest as changes): +- **Claim** — text *(diff)*; type, status, confidence, evidence, entities, + tags, supersedes, superseded_by, contradicts, scope. +- **Page** — body *(diff)*; title, type, status, claims, entities, sources, + tags. + +## CLI — `vouch diff OLD NEW [--json]` + +Follows existing patterns (`_load_store`, `_cli_errors`, `_emit_json`). + +Human output: +``` +diff claim + status: working → stable + confidence: 0.7 → 0.9 + evidence: ['s1'] → ['s1', 's2'] + text: + --- a + +++ b + -old wording + +new wording +``` +- `--json` → `_emit_json` of the `ArtifactDiff` as a dict. +- No differences → prints `no differences`. + +## Error handling + +- Unknown id (neither claim nor page) → `DiffError` → clean CLI `Error:` line. +- Mismatched kinds → `DiffError` → clean CLI `Error:` line. + +## Testing (TDD) + +- `diff_artifacts`: two claims differing in status/confidence → matching + `FieldChange`s; a text change → `text_diff` contains `-`/`+` lines; identical + claims → empty `changes` and `text_diff`; two pages differing in title/body; + unknown id → `DiffError`; claim-vs-page → `DiffError`. +- CLI: `vouch diff a b` prints the changed fields; `--json` emits a dict with + `kind`/`changes`; unknown id → clean `Error:`; identical → `no differences`. + +## Non-goals + +- Following supersede chains automatically (caller passes both ids). +- Diffing entities/relations/sources (claims and pages only, per ROADMAP). +- MCP/JSONL parity (`kb.*` surface unchanged). From 8c45a919d252cf0ec9672cdd146705384bfc4791 Mon Sep 17 00:00:00 2001 From: plind-junior <59729252+plind-junior@users.noreply.github.com> Date: Tue, 26 May 2026 06:22:58 +0900 Subject: [PATCH 007/149] feat(diff): add vouch diff for claim/page revisions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit new read-only `vouch diff ` compares two claims or two pages by id and shows what changed: scalar/list fields as old → new, plus a line-diff of the long text/body. auto-detects kind, hides churning metadata (timestamps, approved_by), supports --json. closes a roadmap 0.1 item. --- CHANGELOG.md | 1 + src/vouch/cli.py | 32 +++++++++++ src/vouch/diff.py | 102 +++++++++++++++++++++++++++++++++++ tests/test_diff.py | 129 +++++++++++++++++++++++++++++++++++++++++++++ 4 files changed, 264 insertions(+) create mode 100644 src/vouch/diff.py create mode 100644 tests/test_diff.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 3c0facc3..1dd0a570 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,7 @@ All notable changes to vouch are documented here. Format follows ## [Unreleased] ### Added +- `vouch diff ` shows what changed between two claim revisions or two page revisions — field-level changes plus a line-diff of the long text/body. Auto-detects the artifact kind and hides always-churning metadata. Read-only; supports `--json`. - Seed a cited starter source and claim during `vouch init`, print first-run next steps, and document a 30-second onboarding tour (#54). diff --git a/src/vouch/cli.py b/src/vouch/cli.py index 100dcdce..7e7a6b4b 100644 --- a/src/vouch/cli.py +++ b/src/vouch/cli.py @@ -768,6 +768,38 @@ def import_apply_cmd(bundle_path: str, on_conflict: str) -> None: _emit_json(r) +# --- diff ----------------------------------------------------------------- + + +@cli.command() +@click.argument("old_id") +@click.argument("new_id") +@click.option("--json", "as_json", is_flag=True, default=False, + help="Emit the diff as JSON.") +def diff(old_id: str, new_id: str, as_json: bool) -> None: + """Show what changed between two claim or two page revisions.""" + from dataclasses import asdict + + from .diff import diff_artifacts + store = _load_store() + with _cli_errors(): + d = diff_artifacts(store, old_id, new_id) + if as_json: + _emit_json(asdict(d)) + return + if not d.changes and not d.text_diff: + click.echo("no differences") + return + click.echo(f"diff {d.kind} {d.old_id} → {d.new_id}") + for c in d.changes: + click.echo(f" {c.field}: {c.old} → {c.new}") + if d.text_diff: + label = "body" if d.kind == "page" else "text" + click.echo(f" {label}:") + for line in d.text_diff: + click.echo(f" {line}") + + # --- serve ---------------------------------------------------------------- diff --git a/src/vouch/diff.py b/src/vouch/diff.py new file mode 100644 index 00000000..bc0a9e92 --- /dev/null +++ b/src/vouch/diff.py @@ -0,0 +1,102 @@ +"""Claim/page revision diff — `vouch diff `. + +Read-only: shows what changed between two claim revisions or two page +revisions. Field-level changes for scalars/lists, plus a line-diff of the long +text field (`claim.text` / `page.body`). No writes, no proposals, no audit. +""" + +from __future__ import annotations + +import difflib +from dataclasses import dataclass +from enum import Enum +from typing import Any + +from .storage import ArtifactNotFoundError, KBStore + +# (long-text field rendered as a line diff, scalar/list fields shown as old→new) +_CLAIM_TEXT = "text" +_CLAIM_FIELDS = [ + "type", "status", "confidence", "evidence", "entities", "tags", + "supersedes", "superseded_by", "contradicts", "scope", +] +_PAGE_TEXT = "body" +_PAGE_FIELDS = ["title", "type", "status", "claims", "entities", "sources", "tags"] + + +class DiffError(ValueError): + """Raised for unknown ids or mismatched artifact kinds. + + Subclasses ValueError so the CLI's `_cli_errors()` renders it as a clean + `Error:` line instead of a traceback. + """ + + +@dataclass +class FieldChange: + field: str + old: Any + new: Any + + +@dataclass +class ArtifactDiff: + kind: str + old_id: str + new_id: str + changes: list[FieldChange] + text_diff: list[str] + + +def _kind_of(store: KBStore, artifact_id: str) -> str | None: + try: + store.get_claim(artifact_id) + return "claim" + except ArtifactNotFoundError: + pass + try: + store.get_page(artifact_id) + return "page" + except ArtifactNotFoundError: + return None + + +def _norm(value: Any) -> Any: + return value.value if isinstance(value, Enum) else value + + +def _line_diff(old: str, new: str) -> list[str]: + return list(difflib.unified_diff( + old.splitlines(), new.splitlines(), lineterm="", + )) + + +def diff_artifacts(store: KBStore, old_id: str, new_id: str) -> ArtifactDiff: + """Diff two same-kind artifacts (both claims or both pages) by id.""" + old_kind = _kind_of(store, old_id) + if old_kind is None: + raise DiffError(f"unknown artifact: {old_id}") + new_kind = _kind_of(store, new_id) + if new_kind is None: + raise DiffError(f"unknown artifact: {new_id}") + if old_kind != new_kind: + raise DiffError(f"cannot diff {old_kind} against {new_kind}") + + if old_kind == "claim": + old, new = store.get_claim(old_id), store.get_claim(new_id) + fields, text_field = _CLAIM_FIELDS, _CLAIM_TEXT + else: + old, new = store.get_page(old_id), store.get_page(new_id) + fields, text_field = _PAGE_FIELDS, _PAGE_TEXT + + changes: list[FieldChange] = [] + for field in fields: + o, n = _norm(getattr(old, field)), _norm(getattr(new, field)) + if o != n: + changes.append(FieldChange(field=field, old=o, new=n)) + + text_diff = _line_diff(getattr(old, text_field), getattr(new, text_field)) + return ArtifactDiff( + kind=old_kind, old_id=old_id, new_id=new_id, + changes=changes, text_diff=text_diff, + ) diff --git a/tests/test_diff.py b/tests/test_diff.py new file mode 100644 index 00000000..4189d806 --- /dev/null +++ b/tests/test_diff.py @@ -0,0 +1,129 @@ +"""Claim/page revision diff — `vouch diff`.""" + +from __future__ import annotations + +from pathlib import Path + +import pytest +from click.testing import CliRunner + +from vouch.cli import cli +from vouch.diff import ArtifactDiff, DiffError, diff_artifacts +from vouch.models import Claim, ClaimStatus, Page +from vouch.storage import KBStore + + +@pytest.fixture +def store(tmp_path: Path) -> KBStore: + return KBStore.init(tmp_path) + + +def _claim(store: KBStore, cid: str, **kw: object) -> Claim: + src = store.put_source(b"e") + fields = {"id": cid, "text": "t", "evidence": [src.id]} + fields.update(kw) + return store.put_claim(Claim(**fields)) # type: ignore[arg-type] + + +# --- diff_artifacts ------------------------------------------------------- + + +def test_diff_claims_reports_changed_scalar_fields(store: KBStore) -> None: + _claim(store, "c1", status=ClaimStatus.WORKING, confidence=0.7) + _claim(store, "c2", status=ClaimStatus.STABLE, confidence=0.9) + d = diff_artifacts(store, "c1", "c2") + assert isinstance(d, ArtifactDiff) + assert d.kind == "claim" + changed = {c.field: (c.old, c.new) for c in d.changes} + assert changed["status"] == ("working", "stable") + assert changed["confidence"] == (0.7, 0.9) + + +def test_diff_claims_text_change_produces_line_diff(store: KBStore) -> None: + _claim(store, "c1", text="the old wording") + _claim(store, "c2", text="the new wording") + d = diff_artifacts(store, "c1", "c2") + assert any(line.startswith("-the old wording") for line in d.text_diff) + assert any(line.startswith("+the new wording") for line in d.text_diff) + # text is rendered as a diff, not as a scalar FieldChange + assert "text" not in {c.field for c in d.changes} + + +def test_diff_identical_claims_has_no_changes(store: KBStore) -> None: + _claim(store, "c1", text="same", status=ClaimStatus.STABLE) + _claim(store, "c2", text="same", status=ClaimStatus.STABLE) + d = diff_artifacts(store, "c1", "c2") + assert d.changes == [] + assert d.text_diff == [] + + +def test_diff_pages_reports_title_and_body(store: KBStore) -> None: + store.put_page(Page(id="p1", title="Old", body="line one")) + store.put_page(Page(id="p2", title="New", body="line two")) + d = diff_artifacts(store, "p1", "p2") + assert d.kind == "page" + changed = {c.field: (c.old, c.new) for c in d.changes} + assert changed["title"] == ("Old", "New") + assert any(line.startswith("+line two") for line in d.text_diff) + + +def test_diff_unknown_id_raises(store: KBStore) -> None: + _claim(store, "c1") + with pytest.raises(DiffError, match="unknown artifact: nope"): + diff_artifacts(store, "c1", "nope") + + +def test_diff_mismatched_kinds_raises(store: KBStore) -> None: + _claim(store, "c1") + store.put_page(Page(id="p1", title="P", body="b")) + with pytest.raises(DiffError, match="cannot diff"): + diff_artifacts(store, "c1", "p1") + + +# --- CLI ------------------------------------------------------------------ + + +def test_cli_diff_prints_changed_fields( + store: KBStore, monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.chdir(store.root) + _claim(store, "c1", text="old", status=ClaimStatus.WORKING) + _claim(store, "c2", text="new", status=ClaimStatus.STABLE) + res = CliRunner().invoke(cli, ["diff", "c1", "c2"]) + assert res.exit_code == 0, res.output + assert "status: working" in res.output and "stable" in res.output + assert "-old" in res.output and "+new" in res.output + + +def test_cli_diff_json(store: KBStore, monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.chdir(store.root) + _claim(store, "c1", status=ClaimStatus.WORKING) + _claim(store, "c2", status=ClaimStatus.STABLE) + res = CliRunner().invoke(cli, ["diff", "c1", "c2", "--json"]) + assert res.exit_code == 0, res.output + import json + payload = json.loads(res.output) + assert payload["kind"] == "claim" + assert any(c["field"] == "status" for c in payload["changes"]) + + +def test_cli_diff_identical_says_no_differences( + store: KBStore, monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.chdir(store.root) + _claim(store, "c1", text="same") + _claim(store, "c2", text="same") + res = CliRunner().invoke(cli, ["diff", "c1", "c2"]) + assert res.exit_code == 0, res.output + assert "no differences" in res.output + + +def test_cli_diff_unknown_id_clean_error( + store: KBStore, monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.chdir(store.root) + _claim(store, "c1") + res = CliRunner().invoke(cli, ["diff", "c1", "nope"]) + assert res.exit_code != 0 + assert "Traceback" not in res.output + assert "unknown artifact: nope" in res.output From e773abaef017bf0c8d9f4c5eb95018bac73d4e52 Mon Sep 17 00:00:00 2001 From: plind-junior <59729252+plind-junior@users.noreply.github.com> Date: Tue, 26 May 2026 06:30:48 +0900 Subject: [PATCH 008/149] fix(diff): annotate old/new as Claim | Page to satisfy mypy mypy inferred old/new as Claim from the first branch, so the page branch's reassignment tripped an incompatible-assignment error in CI. annotate the union up front. --- src/vouch/diff.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/vouch/diff.py b/src/vouch/diff.py index bc0a9e92..8387afa0 100644 --- a/src/vouch/diff.py +++ b/src/vouch/diff.py @@ -12,6 +12,7 @@ from enum import Enum from typing import Any +from .models import Claim, Page from .storage import ArtifactNotFoundError, KBStore # (long-text field rendered as a line diff, scalar/list fields shown as old→new) @@ -82,6 +83,8 @@ def diff_artifacts(store: KBStore, old_id: str, new_id: str) -> ArtifactDiff: if old_kind != new_kind: raise DiffError(f"cannot diff {old_kind} against {new_kind}") + old: Claim | Page + new: Claim | Page if old_kind == "claim": old, new = store.get_claim(old_id), store.get_claim(new_id) fields, text_field = _CLAIM_FIELDS, _CLAIM_TEXT From 555acfc1136df9f21948bd0edf892da1a8e1f68e Mon Sep 17 00:00:00 2001 From: Clayton Date: Mon, 25 May 2026 12:47:49 -0500 Subject: [PATCH 009/149] feat: add guided proposal review CLI --- CHANGELOG.md | 2 + README.md | 1 + src/vouch/cli.py | 100 +++++++++++++++++++++++++++++++++++++++++- tests/test_cli.py | 108 ++++++++++++++++++++++++++++++++++++++++++++++ 4 files changed, 210 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1dd0a570..ae389c77 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,8 @@ All notable changes to vouch are documented here. Format follows - `vouch diff ` shows what changed between two claim revisions or two page revisions — field-level changes plus a line-diff of the long text/body. Auto-detects the artifact kind and hides always-churning metadata. Read-only; supports `--json`. - Seed a cited starter source and claim during `vouch init`, print first-run next steps, and document a 30-second onboarding tour (#54). +- Add `vouch review`, a guided CLI queue for approving, rejecting, skipping, + or dry-running pending proposals without bypassing the review gate. ### Fixed - Add `put_relation_idempotent()` to `KBStore` and use it in `supersede()` and `contradict()` so retrying after a partial failure converges to a consistent state instead of raising `ValueError`. diff --git a/README.md b/README.md index 7df46d18..6397afdf 100644 --- a/README.md +++ b/README.md @@ -142,6 +142,7 @@ vouch lint [--stale-days N] # user-actionable problems vouch doctor # full sweep incl. source verification vouch pending # list pending proposals +vouch review [--limit N] [--type KIND] # guided proposal review queue vouch show vouch approve [--reason ...] vouch reject --reason "..." diff --git a/src/vouch/cli.py b/src/vouch/cli.py index 7e7a6b4b..27d1eb96 100644 --- a/src/vouch/cli.py +++ b/src/vouch/cli.py @@ -26,7 +26,7 @@ from .capabilities import capabilities as build_caps from .context import build_context_pack from .lifecycle import LifecycleError -from .models import ProposalStatus +from .models import Proposal, ProposalKind, ProposalStatus from .onboarding import seed_starter_kb from .proposals import ( ProposalError, @@ -207,6 +207,104 @@ def pending() -> None: click.echo(f" {str(preview).strip()[:120]}") +def _proposal_preview(pr: Proposal) -> str: + preview = ( + pr.payload.get("text") + or pr.payload.get("title") + or pr.payload.get("name") + or pr.payload.get("id") + or "-" + ) + return str(preview).strip() + + +def _show_review_proposal(pr: Proposal, index: int, total: int) -> None: + click.echo(f"\n[{index}/{total}] {pr.id} [{pr.kind.value}] by {pr.proposed_by}") + click.echo(_proposal_preview(pr)) + if pr.rationale: + click.echo(f"rationale: {pr.rationale}") + click.echo() + click.echo(yaml.safe_dump(pr.model_dump(mode="json"), sort_keys=False).rstrip()) + + +@cli.command() +@click.option( + "--limit", + type=click.IntRange(min=1), + default=None, + help="Review at most N proposals.", +) +@click.option( + "--type", + "kind", + type=click.Choice([k.value for k in ProposalKind]), + default=None, + help="Only review proposals of this kind.", +) +@click.option("--dry-run", is_flag=True, help="Show decisions without mutating proposals.") +def review(limit: int | None, kind: str | None, dry_run: bool) -> None: + """Walk pending proposals one at a time for approval or rejection.""" + store = _load_store() + proposals = store.list_proposals(ProposalStatus.PENDING) + if kind is not None: + proposals = [pr for pr in proposals if pr.kind.value == kind] + if limit is not None: + proposals = proposals[:limit] + if not proposals: + click.echo("no pending proposals") + return + + decided = 0 + skipped = 0 + actor = _whoami() + total = len(proposals) + for index, pr in enumerate(proposals, start=1): + _show_review_proposal(pr, index, total) + action = click.prompt( + "Action [a=approve, r=reject, s=skip, q=quit]", + type=click.Choice(["a", "r", "s", "q"], case_sensitive=False), + default="s", + show_choices=False, + ).lower() + if action == "q": + click.echo("Stopped review") + break + if action == "s": + click.echo(f"Skipped {pr.id}") + skipped += 1 + continue + if action == "r": + reason = click.prompt("Rejection reason").strip() + with _cli_errors(): + if not reason: + raise ProposalError("rejection must include a reason (future agent context)") + if not dry_run: + do_reject(store, pr.id, rejected_by=actor, reason=reason) + if dry_run: + click.echo(f"Would reject {pr.id}") + else: + click.echo(f"Rejected {pr.id}") + decided += 1 + continue + + reason = ( + click.prompt("Approval reason", default="", show_default=False).strip() + or None + ) + if dry_run: + click.echo(f"Would approve {pr.id}") + else: + with _cli_errors(): + artifact = do_approve(store, pr.id, approved_by=actor, reason=reason) + click.echo(f"Approved -> {type(artifact).__name__.lower()}/{artifact.id}") + decided += 1 + + if dry_run: + click.echo(f"Review complete: {decided} selected, {skipped} skipped, no changes made") + else: + click.echo(f"Review complete: {decided} decided, {skipped} skipped") + + @cli.command() @click.argument("proposal_id") def show(proposal_id: str) -> None: diff --git a/tests/test_cli.py b/tests/test_cli.py index ac904c7e..c4c17236 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -18,6 +18,7 @@ from vouch import sessions as sess_mod from vouch.cli import cli +from vouch.models import ProposalStatus from vouch.proposals import propose_claim from vouch.storage import KBStore @@ -82,6 +83,113 @@ def test_show_missing_proposal_shows_clean_error(store: KBStore) -> None: _assert_clean_error(result, "proposal no-such-proposal") +def test_review_approves_pending_proposal( + store: KBStore, monkeypatch: pytest.MonkeyPatch +) -> None: + monkeypatch.delenv("VOUCH_AGENT", raising=False) + monkeypatch.setenv("VOUCH_USER", "reviewer") + src = store.put_source(b"e") + pr = propose_claim( + store, + text="review can approve", + evidence=[src.id], + proposed_by="agent", + ) + + result = CliRunner().invoke(cli, ["review"], input="a\n\n") + + assert result.exit_code == 0, result.output + assert "Approved -> claim/review-can-approve" in result.output + assert store.get_claim("review-can-approve").text == "review can approve" + assert store.get_proposal(pr.id).status == ProposalStatus.APPROVED + + +def test_review_rejects_with_reason( + store: KBStore, monkeypatch: pytest.MonkeyPatch +) -> None: + monkeypatch.delenv("VOUCH_AGENT", raising=False) + monkeypatch.setenv("VOUCH_USER", "reviewer") + src = store.put_source(b"e") + pr = propose_claim( + store, + text="review can reject", + evidence=[src.id], + proposed_by="agent", + ) + + result = CliRunner().invoke(cli, ["review"], input="r\nnot true\n") + + assert result.exit_code == 0, result.output + assert f"Rejected {pr.id}" in result.output + decided = store.get_proposal(pr.id) + assert decided.status == ProposalStatus.REJECTED + assert decided.decision_reason == "not true" + + +def test_review_skip_and_quit_leave_proposals_pending(store: KBStore) -> None: + src = store.put_source(b"e") + first = propose_claim( + store, + text="review can skip", + evidence=[src.id], + proposed_by="agent", + ) + second = propose_claim( + store, + text="review can quit", + evidence=[src.id], + proposed_by="agent", + ) + + result = CliRunner().invoke(cli, ["review"], input="s\nq\n") + + assert result.exit_code == 0, result.output + assert "Skipped " in result.output + assert "Stopped review" in result.output + assert store.get_proposal(first.id).status == ProposalStatus.PENDING + assert store.get_proposal(second.id).status == ProposalStatus.PENDING + + +def test_review_dry_run_does_not_mutate( + store: KBStore, monkeypatch: pytest.MonkeyPatch +) -> None: + monkeypatch.delenv("VOUCH_AGENT", raising=False) + monkeypatch.setenv("VOUCH_USER", "reviewer") + src = store.put_source(b"e") + pr = propose_claim( + store, + text="review dry run", + evidence=[src.id], + proposed_by="agent", + ) + + result = CliRunner().invoke(cli, ["review", "--dry-run"], input="a\n\n") + + assert result.exit_code == 0, result.output + assert f"Would approve {pr.id}" in result.output + assert store.get_proposal(pr.id).status == ProposalStatus.PENDING + with pytest.raises(KeyError): + store.get_claim("review-dry-run") + + +def test_review_dry_run_reject_does_not_mutate(store: KBStore) -> None: + src = store.put_source(b"e") + pr = propose_claim( + store, + text="review dry run reject", + evidence=[src.id], + proposed_by="agent", + ) + + result = CliRunner().invoke(cli, ["review", "--dry-run"], input="r\nnot true\n") + + assert result.exit_code == 0, result.output + assert f"Would reject {pr.id}" in result.output + pending = store.get_proposal(pr.id) + assert pending.status == ProposalStatus.PENDING + assert pending.decision_reason is None + + def test_search_fts5_backend_label( store: KBStore, monkeypatch: pytest.MonkeyPatch ) -> None: From 5c881de4991f9973b3d28f50096d2bd3170672e2 Mon Sep 17 00:00:00 2001 From: galuis116 Date: Mon, 25 May 2026 15:57:58 -0700 Subject: [PATCH 010/149] health: lint surfaces legacy uncited claims instead of crashing (#82 review) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The new Claim.evidence min-citation validator (#81) also fires when claims are read back from disk. A KB that has a pre-existing uncited claims/.yaml from before the fix would otherwise crash vouch lint / vouch doctor with a bare pydantic.ValidationError deep in store.list_claims(). Add _load_claims_for_lint(), a per-file iteration that catches pydantic.ValidationError (and any other load error) and surfaces each bad file as a Finding with code='invalid_claim' and an explicit repair hint: 'edit the YAML to add a citation, or delete the file'. lint() also stops calling status() to populate counts — status() calls the strict store.list_claims() which would re-raise on the same files — and builds the counts dict inline from the safely-loaded valid claims. Regression test in tests/test_health.py: - test_lint_surfaces_legacy_uncited_claim_yaml_without_crashing hand-crafts a claims/legacy.yaml with evidence: [] (matches the on-disk shape an older buggy write path would have left), asserts vouch lint runs to completion, surfaces invalid_claim in findings with the repair-hint message, and that the well-formed sibling claim is still discovered. CHANGELOG migration note expanded to describe the repair hint. --- CHANGELOG.md | 10 +++++++- src/vouch/health.py | 61 ++++++++++++++++++++++++++++++++++++++++---- tests/test_health.py | 41 +++++++++++++++++++++++++++++ 3 files changed, 106 insertions(+), 6 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 488b0819..46f15a13 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -54,7 +54,15 @@ All notable changes to vouch are documented here. Format follows all three paths at once; `store.update_claim` additionally re-validates via `Claim.model_validate(...)` before persisting so in-place mutation (`c.evidence = []; store.update_claim(c)`) - also raises before the YAML hits disk. + also raises before the YAML hits disk. **Migration note:** because + the validator also fires when claims are read back, a KB that + already has an uncited `claims/.yaml` on disk from before this + fix would otherwise crash `vouch lint` / `vouch doctor` with a + `pydantic.ValidationError`. `vouch lint` now iterates `claims/` + per-file and surfaces unparseable / uncited YAMLs as + `invalid_claim` findings ("edit the YAML to add a citation, or + delete the file") instead of bailing out — so existing KBs get a + clean repair list rather than a traceback. ## [0.0.1] — 2026-05-17 diff --git a/src/vouch/health.py b/src/vouch/health.py index ac3fe510..6e25cc72 100644 --- a/src/vouch/health.py +++ b/src/vouch/health.py @@ -11,10 +11,12 @@ from datetime import UTC, datetime, timedelta from pathlib import Path +from pydantic import ValidationError + from . import index_db from .audit import count_events -from .models import ClaimStatus, ProposalStatus -from .storage import KBStore, sha256_hex +from .models import Claim, ClaimStatus, ProposalStatus +from .storage import KBStore, _yaml_load, sha256_hex from .verify import verify_all @@ -50,9 +52,41 @@ def status(store: KBStore) -> dict: } -def lint(store: KBStore, *, stale_after_days: int = 180) -> HealthReport: +def _load_claims_for_lint(store: KBStore) -> tuple[list[Claim], list[Finding]]: + """Iterate `claims/*.yaml` one file at a time so a single invalid + YAML can't crash the whole lint sweep — surface it as a finding + and keep going. This is the repair hint for KBs that have legacy + uncited claims from before the Claim.evidence min-citation + validator landed (#81): `vouch lint` lists them as + `invalid_claim` findings so the user can fix or delete the file + rather than seeing a bare `pydantic.ValidationError` traceback.""" + valid: list[Claim] = [] findings: list[Finding] = [] - claims = store.list_claims() + cdir = store.kb_dir / "claims" + if not cdir.is_dir(): + return valid, findings + for p in sorted(cdir.glob("*.yaml")): + cid = p.stem + try: + valid.append(Claim.model_validate(_yaml_load(p.read_text()))) + except ValidationError as e: + tail = str(e).splitlines()[-1].strip() if str(e) else "validation failed" + findings.append(Finding( + "error", "invalid_claim", + f"claim {cid} ({p}) fails model validation: {tail} — " + "edit the YAML to add a citation, or delete the file", + [cid], + )) + except Exception as e: + findings.append(Finding( + "error", "unreadable_claim", + f"claim {cid} ({p}) could not be loaded: {e}", [cid], + )) + return valid, findings + + +def lint(store: KBStore, *, stale_after_days: int = 180) -> HealthReport: + claims, findings = _load_claims_for_lint(store) sources_present = {s.id for s in store.list_sources()} evidence_present = {e.id for e in store.list_evidence()} @@ -106,7 +140,24 @@ def lint(store: KBStore, *, stale_after_days: int = 180) -> HealthReport: )) ok = not any(f.severity == "error" for f in findings) - return HealthReport(ok=ok, findings=findings, counts=status(store)) + # Build counts inline rather than calling status(), because status() + # calls store.list_claims() which is strict and would re-raise on the + # same invalid YAMLs we just surfaced as findings. Use the safely- + # loaded `claims` list so the report is self-consistent. + counts = { + "kb_dir": str(store.kb_dir), + "claims": len(claims), + "pages": len(store.list_pages()), + "sources": len(sources_present), + "entities": len(store.list_entities()), + "relations": len(store.list_relations()), + "evidence": len(evidence_present), + "sessions": len(store.list_sessions()), + "pending_proposals": len(store.list_proposals(ProposalStatus.PENDING)), + "audit_events": count_events(store.kb_dir), + "index_present": (store.kb_dir / index_db.DB_FILENAME).exists(), + } + return HealthReport(ok=ok, findings=findings, counts=counts) def doctor(store: KBStore) -> HealthReport: diff --git a/tests/test_health.py b/tests/test_health.py index 659b9139..ee4c356a 100644 --- a/tests/test_health.py +++ b/tests/test_health.py @@ -44,6 +44,47 @@ def test_doctor_runs_full_sweep(store: KBStore) -> None: assert report.ok is True +def test_lint_surfaces_legacy_uncited_claim_yaml_without_crashing( + store: KBStore, +) -> None: + """Regression for the #82 review: after the Claim.evidence min-citation + validator landed (#81), a KB that already had an uncited claim on + disk from before the fix would crash `vouch lint` / `vouch doctor` + with a bare pydantic.ValidationError. Lint now skips invalid YAMLs + per-file and surfaces them as `invalid_claim` findings so the user + has a clear repair hint (edit the YAML to add a citation, or delete + the file).""" + src = store.put_source(b"e") + store.put_claim(Claim(id="good", text="t", evidence=[src.id])) + + # Hand-craft an uncited claim YAML that the *current* model rejects — + # matches the on-disk shape an older buggy write path could have left. + legacy_uncited = ( + "id: legacy\n" + 'text: "shipped before the validator existed"\n' + "type: fact\n" + "status: stable\n" + "confidence: 1.0\n" + "evidence: []\n" + ) + (store.kb_dir / "claims" / "legacy.yaml").write_text(legacy_uncited) + + report = health.lint(store) + codes = {f.code for f in report.findings} + assert "invalid_claim" in codes, [f.message for f in report.findings] + invalid = next(f for f in report.findings if f.code == "invalid_claim") + assert "legacy" in invalid.object_ids + assert "delete the file" in invalid.message or "add a citation" in invalid.message + assert report.ok is False # invalid_claim is severity=error + + # The good claim is still discoverable — lint didn't bail out at the + # bad one, so the rest of the sweep still ran. + good_findings = [f for f in report.findings if "good" in f.object_ids] + # No errors about the good claim itself (it's well-formed and cites a + # present source). + assert all(f.severity != "error" for f in good_findings), good_findings + + def test_list_claims_filtered_by_status(store: KBStore) -> None: src = store.put_source(b"e") store.put_claim(Claim(id="c1", text="x", evidence=[src.id], From 423c1dd766d3473a53803c5f6c2cc2dd75fd7ec6 Mon Sep 17 00:00:00 2001 From: galuis116 Date: Mon, 25 May 2026 16:10:05 -0700 Subject: [PATCH 011/149] health: widen HealthReport.counts type to dict[str, Any] (CI mypy) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The new inline-built counts in lint() (from 5c881de) is a literal dict with mixed value types (str/int/bool), which mypy correctly inferred as dict[str, object] and rejected against the HealthReport.counts: dict[str, int] annotation. The original status() returned the same mixed dict via an untyped 'dict' return, which masked the mismatch — the narrow type was effectively never checked at the call site. Widen counts to dict[str, Any] to match runtime reality. Also tighten status()'s return annotation from 'dict' to 'dict[str, Any]' for consistency. No caller does arithmetic on counts values; they just echo or pass through, so the widening is risk-free. --- src/vouch/health.py | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/src/vouch/health.py b/src/vouch/health.py index 6e25cc72..6c6dfcf0 100644 --- a/src/vouch/health.py +++ b/src/vouch/health.py @@ -10,6 +10,7 @@ from dataclasses import dataclass, field from datetime import UTC, datetime, timedelta from pathlib import Path +from typing import Any from pydantic import ValidationError @@ -32,10 +33,15 @@ class Finding: class HealthReport: ok: bool findings: list[Finding] = field(default_factory=list) - counts: dict[str, int] = field(default_factory=dict) + # Mixed value types (str/int/bool) — `claims` etc. are ints, + # `kb_dir` is a str, `index_present` is a bool. Was `dict[str, int]` + # but `status()` already returned the mixed dict via an untyped + # `dict` return annotation; the narrow type was effectively never + # checked. Widened to match runtime reality. + counts: dict[str, Any] = field(default_factory=dict) -def status(store: KBStore) -> dict: +def status(store: KBStore) -> dict[str, Any]: """Quick, machine-readable summary. No deep checks.""" return { "kb_dir": str(store.kb_dir), From 079da73eeab7d2a0db56c68e0139023003b1f9fa Mon Sep 17 00:00:00 2001 From: jsdevninja Date: Mon, 25 May 2026 23:21:11 -0500 Subject: [PATCH 012/149] feat(cli): add JSON output for pending proposals --- CHANGELOG.md | 2 ++ src/vouch/cli.py | 6 +++++- tests/test_cli.py | 36 ++++++++++++++++++++++++++++++++++++ 3 files changed, 43 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ae389c77..83eacb3a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,8 @@ All notable changes to vouch are documented here. Format follows ## [Unreleased] ### Added +- `vouch pending --json` emits pending proposals as structured JSON for shell + scripts, CI checks, and multi-agent review dashboards. - `vouch diff ` shows what changed between two claim revisions or two page revisions — field-level changes plus a line-diff of the long text/body. Auto-detects the artifact kind and hides always-churning metadata. Read-only; supports `--json`. - Seed a cited starter source and claim during `vouch init`, print first-run next steps, and document a 30-second onboarding tour (#54). diff --git a/src/vouch/cli.py b/src/vouch/cli.py index 27d1eb96..8b577713 100644 --- a/src/vouch/cli.py +++ b/src/vouch/cli.py @@ -189,10 +189,14 @@ def doctor() -> None: @cli.command() -def pending() -> None: +@click.option("--json", "as_json", is_flag=True, help="Emit JSON instead of text.") +def pending(as_json: bool) -> None: """List proposals awaiting review.""" store = _load_store() pending = store.list_proposals(ProposalStatus.PENDING) + if as_json: + _emit_json([pr.model_dump(mode="json") for pr in pending]) + return if not pending: click.echo("no pending proposals") return diff --git a/tests/test_cli.py b/tests/test_cli.py index c4c17236..813c901b 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -10,6 +10,7 @@ from __future__ import annotations +import json from pathlib import Path from unittest.mock import patch @@ -83,6 +84,41 @@ def test_show_missing_proposal_shows_clean_error(store: KBStore) -> None: _assert_clean_error(result, "proposal no-such-proposal") +def test_pending_json_empty_queue(store: KBStore) -> None: + result = CliRunner().invoke(cli, ["pending", "--json"]) + + assert result.exit_code == 0, result.output + assert json.loads(result.output) == [] + + +def test_pending_json_lists_pending_proposals(store: KBStore) -> None: + src = store.put_source(b"e") + pr = propose_claim(store, text="pending json claim", evidence=[src.id], proposed_by="agent") + + result = CliRunner().invoke(cli, ["pending", "--json"]) + + assert result.exit_code == 0, result.output + rows = json.loads(result.output) + assert len(rows) == 1 + assert rows[0]["id"] == pr.id + assert rows[0]["kind"] == "claim" + assert rows[0]["proposed_by"] == "agent" + assert rows[0]["status"] == "pending" + assert rows[0]["payload"]["text"] == "pending json claim" + + +def test_pending_human_output_remains_text(store: KBStore) -> None: + src = store.put_source(b"e") + pr = propose_claim(store, text="pending text claim", evidence=[src.id], proposed_by="agent") + + result = CliRunner().invoke(cli, ["pending"]) + + assert result.exit_code == 0, result.output + assert pr.id in result.output + assert "[claim] by agent" in result.output + assert "pending text claim" in result.output + + def test_review_approves_pending_proposal( store: KBStore, monkeypatch: pytest.MonkeyPatch ) -> None: From e3472e7aba44f0e606e4370da5461acb39d3389d Mon Sep 17 00:00:00 2001 From: jsdevninja Date: Mon, 25 May 2026 23:43:28 -0500 Subject: [PATCH 013/149] feat: add deterministic sync workflow for diverged KBs --- README.md | 2 + docs/multi-agent.md | 22 ++- src/vouch/cli.py | 39 ++++- src/vouch/storage.py | 5 +- src/vouch/sync.py | 335 +++++++++++++++++++++++++++++++++++++++++++ tests/test_sync.py | 114 +++++++++++++++ 6 files changed, 509 insertions(+), 8 deletions(-) create mode 100644 src/vouch/sync.py create mode 100644 tests/test_sync.py diff --git a/README.md b/README.md index 6397afdf..60d351a0 100644 --- a/README.md +++ b/README.md @@ -174,6 +174,8 @@ vouch export --out path.tar.gz vouch export-check path.tar.gz vouch import-check path.tar.gz vouch import-apply path.tar.gz [--on-conflict skip|overwrite|fail] +vouch sync-check PATH_OR_BUNDLE +vouch sync-apply PATH_OR_BUNDLE [--on-conflict fail|skip|propose] vouch serve [--transport stdio|jsonl] ``` diff --git a/docs/multi-agent.md b/docs/multi-agent.md index b4918dea..9aa04291 100644 --- a/docs/multi-agent.md +++ b/docs/multi-agent.md @@ -94,13 +94,25 @@ vouch session start --task "implement password reset" \ --note "tag:agent:claude-code-anna" ``` +## Distributed sync + +When two teammates each have their own `.vouch/` directory, use the +sync workflow to reconcile them deterministically: + +```bash +vouch sync-check ../other-repo +vouch sync-apply ../other-repo --on-conflict fail +``` + +`sync-check` accepts either another repo / `.vouch` directory or a +bundle. It reports new files, identical files, and conflicts without +writing anything. `sync-apply` imports non-conflicting files only; it +never overwrites reviewed knowledge. Use `--on-conflict skip` to leave +conflicts untouched, or `--on-conflict propose` to write a local conflict +report under `proposed/sync-reports/` for human review. + ## What doesn't work yet -- **Distributed `.vouch/` directories that sync.** Today it's one - filesystem. If two teammates each have their own `.vouch/` and want - them to merge, the path is bundle export + import-check, manually, - for now. See [bundles.md](bundles.md) and the multi-agent-sync - roadmap item. - **Live merge conflicts.** Two agents editing the same proposal at once isn't a scenario vouch addresses — agents create proposals, they don't edit existing ones. diff --git a/src/vouch/cli.py b/src/vouch/cli.py index 8b577713..02210b2d 100644 --- a/src/vouch/cli.py +++ b/src/vouch/cli.py @@ -13,6 +13,7 @@ import sys from collections.abc import Iterator from contextlib import contextmanager +from dataclasses import asdict from pathlib import Path import click @@ -22,6 +23,7 @@ from . import audit as audit_mod from . import lifecycle as life from . import sessions as sess_mod +from . import sync as sync_mod from . import verify as verify_mod from .capabilities import capabilities as build_caps from .context import build_context_pack @@ -870,6 +872,41 @@ def import_apply_cmd(bundle_path: str, on_conflict: str) -> None: _emit_json(r) +# --- sync ------------------------------------------------------------------ + + +@cli.command("sync-check") +@click.argument("source_path", type=click.Path(exists=True)) +def sync_check_cmd(source_path: str) -> None: + """Compare another .vouch directory or bundle without writing.""" + store = _load_store() + try: + r = sync_mod.sync_check(store.kb_dir, Path(source_path)) + except RuntimeError as e: + raise click.ClickException(str(e)) from e + _emit_json(asdict(r)) + + +@cli.command("sync-apply") +@click.argument("source_path", type=click.Path(exists=True)) +@click.option("--on-conflict", default="fail", show_default=True, + type=click.Choice(["fail", "skip", "propose"])) +def sync_apply_cmd(source_path: str, on_conflict: str) -> None: + """Apply non-conflicting files from another .vouch directory or bundle.""" + store = _load_store() + try: + r = sync_mod.sync_apply( + store.kb_dir, + Path(source_path), + on_conflict=on_conflict, + actor=_whoami(), + ) + except (RuntimeError, ValueError) as e: + raise click.ClickException(str(e)) from e + health.rebuild_index(store) + _emit_json(r) + + # --- diff ----------------------------------------------------------------- @@ -880,8 +917,6 @@ def import_apply_cmd(bundle_path: str, on_conflict: str) -> None: help="Emit the diff as JSON.") def diff(old_id: str, new_id: str, as_json: bool) -> None: """Show what changed between two claim or two page revisions.""" - from dataclasses import asdict - from .diff import diff_artifacts store = _load_store() with _cli_errors(): diff --git a/src/vouch/storage.py b/src/vouch/storage.py index 388e53e3..35a25ae1 100644 --- a/src/vouch/storage.py +++ b/src/vouch/storage.py @@ -119,6 +119,7 @@ def _serialize_page(page: Page) -> str: def _deserialize_page(text: str) -> Page: + text = text.replace("\r\n", "\n") m = _FRONTMATTER_RE.match(text) if not m: raise ValueError("page file missing YAML frontmatter") @@ -151,8 +152,10 @@ def read_under_root(self, path: str | Path) -> tuple[Path, bytes]: raise ValueError( f"path must be inside project root ({self.root}): {resolved}" ) + if resolved.is_dir(): + raise ValueError(f"not a regular file: {resolved}") try: - fd = os.open(resolved, os.O_RDONLY | os.O_NOFOLLOW) + fd = os.open(resolved, os.O_RDONLY | getattr(os, "O_NOFOLLOW", 0)) except OSError as e: raise ValueError(f"cannot read {resolved}: {e}") from e try: diff --git a/src/vouch/sync.py b/src/vouch/sync.py new file mode 100644 index 00000000..d2477ab7 --- /dev/null +++ b/src/vouch/sync.py @@ -0,0 +1,335 @@ +"""Deterministic sync for reconciling another vouch KB or bundle. + +The sync surface is deliberately conservative: it imports files that are +absent locally, reports identical files, and never overwrites divergent +reviewed knowledge. Conflicts can fail the operation, be skipped, or be +captured in a local conflict report under ``proposed/sync-reports/``. +""" + +from __future__ import annotations + +import json +import tarfile +from dataclasses import asdict, dataclass +from pathlib import Path +from typing import Any + +from . import audit, bundle +from .storage import sha256_hex + + +@dataclass +class IncomingFile: + path: str + size: int + sha256: str + + +@dataclass +class SyncConflict: + path: str + kind: str + artifact_id: str | None + reason: str + local_sha256: str + incoming_sha256: str + + +@dataclass +class SyncCheckResult: + ok: bool + source_type: str + source_id: str + source: str + new_files: list[str] + identical: list[str] + conflicts: list[SyncConflict] + semantic_conflicts: list[SyncConflict] + decided_conflicts: list[SyncConflict] + issues: list[str] + + +@dataclass +class _SyncSource: + source_type: str + source_id: str + display: str + files: dict[str, IncomingFile] + root: Path | None = None + bundle_path: Path | None = None + + +def _artifact_kind(path: str) -> tuple[str, str | None]: + if path == "config.yaml": + return "config", None + parts = path.split("/") + top = parts[0] + if top == "sources" and len(parts) >= 3: + return "source", parts[1] + if len(parts) < 2: + return top, None + artifact_id = Path(parts[-1]).stem + singular = { + "claims": "claim", + "pages": "page", + "entities": "entity", + "relations": "relation", + "evidence": "evidence", + "sessions": "session", + "decided": "decided-proposal", + }.get(top, top) + return singular, artifact_id + + +def _conflict(path: str, local_sha: str, incoming_sha: str) -> SyncConflict: + kind, artifact_id = _artifact_kind(path) + if kind == "config": + reason = "local config differs from incoming config" + elif kind == "decided-proposal": + reason = f"decided proposal {artifact_id} differs" + elif artifact_id is not None: + reason = f"{kind} {artifact_id} exists with different content" + else: + reason = f"{kind} file exists with different content" + return SyncConflict( + path=path, + kind=kind, + artifact_id=artifact_id, + reason=reason, + local_sha256=local_sha, + incoming_sha256=incoming_sha, + ) + + +def _source_id(files: dict[str, IncomingFile]) -> str: + h = sha256_hex( + "\n".join( + f"{path}\0{file.sha256}" for path, file in sorted(files.items()) + ).encode() + ) + return h + + +def _resolve_kb_dir(source_path: Path) -> Path: + if (source_path / ".vouch").is_dir(): + return source_path / ".vouch" + if source_path.name == ".vouch" and source_path.is_dir(): + return source_path + raise RuntimeError(f"sync source is not a vouch KB or bundle: {source_path}") + + +def _load_directory_source(source_path: Path) -> _SyncSource: + kb_dir = _resolve_kb_dir(source_path) + files: dict[str, IncomingFile] = {} + for rel, abs_path in bundle._iter_export_files(kb_dir): + path = rel.as_posix() + data = abs_path.read_bytes() + files[path] = IncomingFile(path=path, size=len(data), sha256=sha256_hex(data)) + return _SyncSource( + source_type="kb", + source_id=_source_id(files), + display=str(source_path), + files=files, + root=kb_dir, + ) + + +def _load_bundle_source(source_path: Path) -> _SyncSource: + with tarfile.open(source_path, "r:gz") as tar: + try: + member = tar.getmember(bundle.MANIFEST_NAME) + except KeyError as exc: + raise RuntimeError("bundle missing manifest.json") from exc + manifest = json.loads(tar.extractfile(member).read().decode()) # type: ignore[union-attr] + files = { + f["path"]: IncomingFile( + path=f["path"], + size=int(f.get("size", 0)), + sha256=str(f.get("sha256", "")), + ) + for f in manifest.get("files", []) + } + return _SyncSource( + source_type="bundle", + source_id=manifest.get("bundle_id") or _source_id(files), + display=str(source_path), + files=files, + bundle_path=source_path, + ) + + +def _load_source(source_path: Path) -> _SyncSource: + source_path = source_path.resolve() + if source_path.is_dir(): + return _load_directory_source(source_path) + if source_path.is_file(): + return _load_bundle_source(source_path) + raise RuntimeError(f"sync source does not exist: {source_path}") + + +def _read_source_file(src: _SyncSource, path: str) -> bytes: + if src.root is not None: + return (src.root / path).read_bytes() + if src.bundle_path is None: + raise RuntimeError("sync source has no readable backing store") + with tarfile.open(src.bundle_path, "r:gz") as tar: + return tar.extractfile(tar.getmember(path)).read() # type: ignore[union-attr] + + +def _validation_issues_for_source(src: _SyncSource) -> list[str]: + issues: list[str] = [] + if src.bundle_path is not None: + check = bundle.export_check(src.bundle_path) + issues.extend(check.issues) + for path, incoming in sorted(src.files.items()): + reason = bundle._unsafe_name_reason(path) + if reason is not None: + issues.append(reason) + continue + try: + data = _read_source_file(src, path) + except Exception as exc: + issues.append(f"cannot read sync source member {path}: {exc}") + continue + if sha256_hex(data) != incoming.sha256: + issues.append(f"hash mismatch: {path}") + continue + bundle._validate_content(path, data, issues) + return issues + + +def sync_check(kb_dir: Path, source_path: Path) -> SyncCheckResult: + """Compare another KB or bundle with ``kb_dir`` without writing.""" + src = _load_source(source_path) + issues = _validation_issues_for_source(src) + new_files: list[str] = [] + identical: list[str] = [] + conflicts: list[SyncConflict] = [] + + for path, incoming in sorted(src.files.items()): + try: + dest = bundle._safe_member_path(kb_dir, path) + except RuntimeError as exc: + issues.append(str(exc)) + continue + if not dest.exists(): + new_files.append(path) + continue + local_sha = sha256_hex(dest.read_bytes()) + if local_sha == incoming.sha256: + identical.append(path) + else: + conflicts.append(_conflict(path, local_sha, incoming.sha256)) + + semantic_conflicts = [ + c for c in conflicts + if c.kind in {"claim", "page", "entity", "relation", "evidence", "session"} + ] + decided_conflicts = [c for c in conflicts if c.kind == "decided-proposal"] + return SyncCheckResult( + ok=not issues, + source_type=src.source_type, + source_id=src.source_id, + source=src.display, + new_files=new_files, + identical=identical, + conflicts=conflicts, + semantic_conflicts=semantic_conflicts, + decided_conflicts=decided_conflicts, + issues=issues, + ) + + +def _write_conflict_report( + kb_dir: Path, + check: SyncCheckResult, + *, + on_conflict: str, +) -> str: + report_dir = kb_dir / "proposed" / "sync-reports" + report_dir.mkdir(parents=True, exist_ok=True) + report_path = report_dir / f"{check.source_id}.json" + report = asdict(check) + report["on_conflict"] = on_conflict + report_path.write_text(json.dumps(report, indent=2, sort_keys=True)) + return str(report_path.relative_to(kb_dir)) + + +def sync_apply( + kb_dir: Path, + source_path: Path, + *, + on_conflict: str = "fail", + actor: str = "vouch-sync", +) -> dict[str, Any]: + """Apply non-conflicting incoming files from another KB or bundle. + + ``on_conflict`` may be: + - ``fail``: abort if any incoming path conflicts. + - ``skip``: import new files and leave conflicts untouched. + - ``propose``: import new files and write a local sync conflict report. + """ + if on_conflict not in {"fail", "skip", "propose"}: + raise ValueError(f"on_conflict must be fail|skip|propose, got {on_conflict}") + + src = _load_source(source_path) + check = sync_check(kb_dir, source_path) + if check.issues: + raise RuntimeError(f"refusing to sync: {check.issues[0]}") + if on_conflict == "fail" and check.conflicts: + raise RuntimeError(f"refusing to sync: {len(check.conflicts)} conflicts") + + written: list[str] = [] + skipped_conflicts: list[str] = [] + for path, incoming in sorted(src.files.items()): + dest = bundle._safe_member_path(kb_dir, path) + if dest.exists(): + local_sha = sha256_hex(dest.read_bytes()) + if local_sha == incoming.sha256: + continue + if on_conflict == "fail": + raise RuntimeError(f"refusing to sync conflicting path: {path}") + skipped_conflicts.append(path) + continue + + data = _read_source_file(src, path) + if sha256_hex(data) != incoming.sha256: + raise RuntimeError(f"refusing to sync: hash mismatch at write time: {path}") + val_issues: list[str] = [] + bundle._validate_content(path, data, val_issues) + if val_issues: + raise RuntimeError(f"refusing to sync: {val_issues[0]}") + dest.parent.mkdir(parents=True, exist_ok=True) + dest.write_bytes(data) + written.append(path) + + report_path = None + if on_conflict == "propose" and check.conflicts: + report_path = _write_conflict_report( + kb_dir, check, on_conflict=on_conflict, + ) + + result = { + "source_type": check.source_type, + "source_id": check.source_id, + "written": written, + "skipped_conflicts": skipped_conflicts, + "identical": check.identical, + "conflicts": [asdict(c) for c in check.conflicts], + "conflict_report": report_path, + "on_conflict": on_conflict, + } + audit.log_event( + kb_dir, + event="sync.apply", + actor=actor, + object_ids=[check.source_id], + data={ + "source_type": check.source_type, + "written": len(written), + "skipped_conflicts": len(skipped_conflicts), + "on_conflict": on_conflict, + "conflict_report": report_path, + }, + ) + return result diff --git a/tests/test_sync.py b/tests/test_sync.py new file mode 100644 index 00000000..0a8c0272 --- /dev/null +++ b/tests/test_sync.py @@ -0,0 +1,114 @@ +"""Deterministic sync / merge workflow.""" + +from __future__ import annotations + +import json +from pathlib import Path + +import pytest +from click.testing import CliRunner + +from vouch import bundle, sync +from vouch.cli import cli +from vouch.models import Claim +from vouch.storage import KBStore + + +def _store(root: Path) -> KBStore: + return KBStore.init(root) + + +def _claim(store: KBStore, claim_id: str, text: str) -> None: + src = store.put_source(b"shared evidence", title="evidence") + store.put_claim(Claim(id=claim_id, text=text, evidence=[src.id])) + + +def test_sync_check_and_apply_from_kb_directory(tmp_path: Path) -> None: + incoming = _store(tmp_path / "incoming") + _claim(incoming, "c1", "alpha") + dest = _store(tmp_path / "dest") + + report = sync.sync_check(dest.kb_dir, incoming.root) + + assert report.ok + assert report.source_type == "kb" + assert "claims/c1.yaml" in report.new_files + assert not report.conflicts + + result = sync.sync_apply(dest.kb_dir, incoming.root, actor="tester") + + assert "claims/c1.yaml" in result["written"] + assert dest.get_claim("c1").text == "alpha" + + +def test_sync_check_classifies_claim_conflicts(tmp_path: Path) -> None: + incoming = _store(tmp_path / "incoming") + _claim(incoming, "c1", "incoming text") + dest = _store(tmp_path / "dest") + _claim(dest, "c1", "local text") + + report = sync.sync_check(dest.kb_dir, incoming.root) + + assert report.ok + assert any(c.path == "claims/c1.yaml" for c in report.conflicts) + assert any( + c.kind == "claim" and c.artifact_id == "c1" + for c in report.semantic_conflicts + ) + + +def test_sync_apply_fails_on_conflicts_by_default(tmp_path: Path) -> None: + incoming = _store(tmp_path / "incoming") + _claim(incoming, "c1", "incoming text") + dest = _store(tmp_path / "dest") + _claim(dest, "c1", "local text") + + with pytest.raises(RuntimeError, match="conflicts"): + sync.sync_apply(dest.kb_dir, incoming.root) + + assert dest.get_claim("c1").text == "local text" + + +def test_sync_apply_propose_writes_conflict_report(tmp_path: Path) -> None: + incoming = _store(tmp_path / "incoming") + _claim(incoming, "c1", "incoming text") + dest = _store(tmp_path / "dest") + _claim(dest, "c1", "local text") + + result = sync.sync_apply(dest.kb_dir, incoming.root, on_conflict="propose") + + assert dest.get_claim("c1").text == "local text" + assert "claims/c1.yaml" in result["skipped_conflicts"] + report_path = dest.kb_dir / result["conflict_report"] + report = json.loads(report_path.read_text()) + assert any(c["path"] == "claims/c1.yaml" for c in report["conflicts"]) + + +def test_sync_check_accepts_bundle_source(tmp_path: Path) -> None: + incoming = _store(tmp_path / "incoming") + _claim(incoming, "c1", "alpha") + bundle_path = tmp_path / "incoming.tar.gz" + bundle.export(incoming.kb_dir, dest=bundle_path) + dest = _store(tmp_path / "dest") + + report = sync.sync_check(dest.kb_dir, bundle_path) + + assert report.ok + assert report.source_type == "bundle" + assert "claims/c1.yaml" in report.new_files + + +def test_sync_check_cli_outputs_report( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + incoming = _store(tmp_path / "incoming") + _claim(incoming, "c1", "alpha") + dest = _store(tmp_path / "dest") + monkeypatch.chdir(dest.root) + + result = CliRunner().invoke(cli, ["sync-check", str(incoming.root)]) + + assert result.exit_code == 0, result.output + payload = json.loads(result.output) + assert payload["source_type"] == "kb" + assert "claims/c1.yaml" in payload["new_files"] From a764129e73ccb75cc7308bb3e12a091e0c685860 Mon Sep 17 00:00:00 2001 From: jsdevninja Date: Tue, 26 May 2026 00:03:21 -0500 Subject: [PATCH 014/149] fix: update --- CHANGELOG.md | 3 +++ docs/multi-agent.md | 3 ++- src/vouch/storage.py | 6 +++++- src/vouch/sync.py | 14 +++++++++++++- tests/test_sync.py | 17 +++++++++++++++++ 5 files changed, 40 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 83eacb3a..92d5cc73 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,9 @@ All notable changes to vouch are documented here. Format follows ## [Unreleased] ### Added +- `vouch sync-check` and `vouch sync-apply` reconcile another `.vouch` + directory or bundle by importing only non-conflicting durable artifacts and + reporting conflicts without overwriting reviewed knowledge. - `vouch pending --json` emits pending proposals as structured JSON for shell scripts, CI checks, and multi-agent review dashboards. - `vouch diff ` shows what changed between two claim revisions or two page revisions — field-level changes plus a line-diff of the long text/body. Auto-detects the artifact kind and hides always-churning metadata. Read-only; supports `--json`. diff --git a/docs/multi-agent.md b/docs/multi-agent.md index 9aa04291..65c8cdb7 100644 --- a/docs/multi-agent.md +++ b/docs/multi-agent.md @@ -109,7 +109,8 @@ bundle. It reports new files, identical files, and conflicts without writing anything. `sync-apply` imports non-conflicting files only; it never overwrites reviewed knowledge. Use `--on-conflict skip` to leave conflicts untouched, or `--on-conflict propose` to write a local conflict -report under `proposed/sync-reports/` for human review. +report under `proposed/sync-reports/` for human review. `config.yaml` +stays local to each KB and is not synced. ## What doesn't work yet diff --git a/src/vouch/storage.py b/src/vouch/storage.py index 35a25ae1..797f9a11 100644 --- a/src/vouch/storage.py +++ b/src/vouch/storage.py @@ -154,8 +154,12 @@ def read_under_root(self, path: str | Path) -> tuple[Path, bytes]: ) if resolved.is_dir(): raise ValueError(f"not a regular file: {resolved}") + flags = os.O_RDONLY + # POSIX can reject a symlink swapped in after resolve(); Windows has + # no O_NOFOLLOW, so it falls back to the regular-file check below. + flags |= getattr(os, "O_NOFOLLOW", 0) try: - fd = os.open(resolved, os.O_RDONLY | getattr(os, "O_NOFOLLOW", 0)) + fd = os.open(resolved, flags) except OSError as e: raise ValueError(f"cannot read {resolved}: {e}") from e try: diff --git a/src/vouch/sync.py b/src/vouch/sync.py index d2477ab7..a3ccc89a 100644 --- a/src/vouch/sync.py +++ b/src/vouch/sync.py @@ -17,6 +17,8 @@ from . import audit, bundle from .storage import sha256_hex +_SYNC_EXCLUDED_PATHS = {"config.yaml"} + @dataclass class IncomingFile: @@ -110,6 +112,14 @@ def _source_id(files: dict[str, IncomingFile]) -> str: return h +def _syncable_files(files: dict[str, IncomingFile]) -> dict[str, IncomingFile]: + return { + path: file + for path, file in files.items() + if path not in _SYNC_EXCLUDED_PATHS + } + + def _resolve_kb_dir(source_path: Path) -> Path: if (source_path / ".vouch").is_dir(): return source_path / ".vouch" @@ -125,6 +135,7 @@ def _load_directory_source(source_path: Path) -> _SyncSource: path = rel.as_posix() data = abs_path.read_bytes() files[path] = IncomingFile(path=path, size=len(data), sha256=sha256_hex(data)) + files = _syncable_files(files) return _SyncSource( source_type="kb", source_id=_source_id(files), @@ -149,9 +160,10 @@ def _load_bundle_source(source_path: Path) -> _SyncSource: ) for f in manifest.get("files", []) } + files = _syncable_files(files) return _SyncSource( source_type="bundle", - source_id=manifest.get("bundle_id") or _source_id(files), + source_id=_source_id(files), display=str(source_path), files=files, bundle_path=source_path, diff --git a/tests/test_sync.py b/tests/test_sync.py index 0a8c0272..28245cf1 100644 --- a/tests/test_sync.py +++ b/tests/test_sync.py @@ -41,6 +41,23 @@ def test_sync_check_and_apply_from_kb_directory(tmp_path: Path) -> None: assert dest.get_claim("c1").text == "alpha" +def test_sync_excludes_config_yaml(tmp_path: Path) -> None: + incoming = _store(tmp_path / "incoming") + incoming.config_path.write_text("version: 1\nsync: incoming\n") + _claim(incoming, "c1", "alpha") + dest = _store(tmp_path / "dest") + dest.config_path.write_text("version: 1\nsync: local\n") + + report = sync.sync_check(dest.kb_dir, incoming.root) + + assert "config.yaml" not in report.new_files + assert "config.yaml" not in report.identical + assert not any(c.path == "config.yaml" for c in report.conflicts) + + sync.sync_apply(dest.kb_dir, incoming.root) + assert dest.config_path.read_text() == "version: 1\nsync: local\n" + + def test_sync_check_classifies_claim_conflicts(tmp_path: Path) -> None: incoming = _store(tmp_path / "incoming") _claim(incoming, "c1", "incoming text") From eafeed643b2dfb26def8bd91537450fde6d8d8ee Mon Sep 17 00:00:00 2001 From: dripsmvcp <138900956+dripsmvcp@users.noreply.github.com> Date: Tue, 26 May 2026 16:02:16 +0900 Subject: [PATCH 015/149] fix(context): honor retrieval.backend config instead of hardcoding embeddings (#92) --- CHANGELOG.md | 6 ++ README.md | 4 +- ROADMAP.md | 7 ++- src/vouch/context.py | 71 ++++++++++++++++++---- src/vouch/storage.py | 4 +- tests/test_retrieval_backend.py | 101 ++++++++++++++++++++++++++++++++ 6 files changed, 176 insertions(+), 17 deletions(-) create mode 100644 tests/test_retrieval_backend.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 92d5cc73..cdd71d49 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -29,6 +29,12 @@ All notable changes to vouch are documented here. Format follows the same tarball. `import_apply`, `import_check`, and `export_check` now validate every member path and raise on unsafe names. - Fix `vouch search` CLI: assign backend label per code path so substring fallback results are no longer mislabelled as `fts5`; update stale docstring to reflect multi-backend search surface (#52). +- `context._retrieve` now honors `retrieval.backend` in `config.yaml` + instead of always running embeddings first (#92). Accepts `auto` + (default — embedding → FTS5 → substring), `embedding`, `fts5`, or + `substring`; a legacy `retrieval.backends` list is still read for + back-compat. `vouch init` now writes `retrieval.backend: auto`, and the + README/ROADMAP describe the actual behavior. - `vouch crystallize` now indexes its session-summary page into FTS5 so it surfaces from `vouch search` / `kb.search` / `kb.context` without a `vouch index` rebuild. Previously the summary was written via diff --git a/README.md b/README.md index 60d351a0..9d6ac77d 100644 --- a/README.md +++ b/README.md @@ -258,7 +258,7 @@ vouch import-apply kb.tar.gz --on-conflict skip # apply (default skip; never de | Tool servers | MCP over stdio + JSONL over stdin/stdout, same `kb.*` surface across both transports, capabilities + knowledge-capability descriptor | | Schemas | 13 JSON Schemas (Draft 2020-12) generated from pydantic in [schemas/](schemas/), plus hand-maintained `bundle.manifest` and `jsonl-envelope` schemas | | Write safety | review-gated writes via [proposed/](spec/review-gate.md), `dry_run:true` previews, host trust required for `approve`/`reject`, atomic exclusive-create storage, path-traversal blocked on source intake and bundle import | -| Retrieval | SQLite FTS5 + substring fallback; optional semantic backends (`all-mpnet-base-v2`, `MiniLM-L6`, fastembed-BGE) behind install extras; context packs with citations + quality gate | +| Retrieval | `retrieval.backend` in `config.yaml` selects the path: `auto` (default — embedding → FTS5 → substring), `embedding`, `fts5`, or `substring`. Semantic backends (`all-mpnet-base-v2`, `MiniLM-L6`, fastembed-BGE) ship behind install extras; `auto` degrades to FTS5 when they aren't installed. Context packs with citations + quality gate | | Lifecycle | `supersede`, `contradict`, `archive`, `confirm`, `cite` — direct mutations, all audited | | Portability | tar.gz bundles with per-file sha256 `manifest.json`, `export-check`, `import-check`, `import-apply` with skip/overwrite/fail conflict modes | | Audit | append-only `audit.log.jsonl`, per-event actor (`VOUCH_AGENT`), object ids, dry-run flag, reversible flag | @@ -268,7 +268,7 @@ vouch import-apply kb.tar.gz --on-conflict skip # apply (default skip; never de ## Status -Pre-1.0. What's *not* in this implementation: vector embeddings (BM25/FTS5 only), per-runtime adapter templates, benchmark fixtures, multi-agent sync, scopes beyond a single field on Claim/Source. If a hole matters to you, file an issue. +Pre-1.0. What's *not* in this implementation: per-runtime adapter templates, benchmark fixtures, multi-agent sync, scopes beyond a single field on Claim/Source. If a hole matters to you, file an issue. ## License diff --git a/ROADMAP.md b/ROADMAP.md index 4e5e661f..46101d1f 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -6,8 +6,11 @@ promise. Items marked **[VEP]** require a written proposal in ## 0.1 — surface stabilises (next) -- Vector embeddings as an *optional* retrieval backend alongside FTS5. - Default stays FTS5; embeddings opt-in via `config.yaml`. **[VEP]** +- Vector embeddings as a retrieval backend alongside FTS5 (shipped). + Retrieval is controlled by `retrieval.backend` in `config.yaml`: + `auto` (default) tries embedding → FTS5 → substring, gracefully + degrading to FTS5 when the embeddings extras aren't installed; set + `embedding`, `fts5`, or `substring` to pin a single path. - `vouch diff ` for claim/page revisions. - `vouch approve --batch` for reviewing N proposals in one transaction. - HTTP transport (`vouch serve --transport http`) behind a localhost diff --git a/src/vouch/context.py b/src/vouch/context.py index 667fb518..988dee0f 100644 --- a/src/vouch/context.py +++ b/src/vouch/context.py @@ -16,30 +16,77 @@ import sqlite3 from typing import Any, Literal, cast +import yaml + from . import index_db from .models import ContextItem, ContextPack, ContextQuality from .storage import ArtifactNotFoundError, KBStore ContextItemKind = Literal["claim", "page", "entity", "relation", "source"] +_VALID_BACKENDS = ("auto", "embedding", "fts5", "substring") + + +def _configured_backend(store: KBStore) -> str: + """Resolve the retrieval backend from `config.yaml`, defaulting to "auto". + + Reads the singular `retrieval.backend` string. For KBs initialised + before this knob existed, a legacy `retrieval.backends` list is honoured + by taking its first recognised entry. Anything unreadable or unrecognised + falls back to "auto". + """ + try: + loaded = yaml.safe_load(store.config_path.read_text()) + except (OSError, yaml.YAMLError): + return "auto" + if not isinstance(loaded, dict): + return "auto" + retrieval = loaded.get("retrieval") + if not isinstance(retrieval, dict): + return "auto" + backend = retrieval.get("backend") + if isinstance(backend, str) and backend in _VALID_BACKENDS: + return backend + legacy = retrieval.get("backends") + if isinstance(legacy, list): + for entry in legacy: + if isinstance(entry, str) and entry in _VALID_BACKENDS: + return entry + return "auto" + def _retrieve(store: KBStore, query: str, limit: int ) -> list[tuple[str, str, str, float, str]]: """Return list of (kind, id, summary, score, backend). - Dispatch order: embedding (semantic) -> FTS5 -> substring. + The backend is chosen by `retrieval.backend` in config.yaml: + - "auto" (default): embedding -> FTS5 -> substring + - "embedding": semantic search only + - "fts5": lexical FTS5 only + - "substring": substring scan only """ - raw = index_db.search_semantic(store.kb_dir, query, limit=limit) - if raw: - return [(k, i, s, sc, "embedding") for k, i, s, sc in raw] - try: - hits = index_db.search(store.kb_dir, query, limit=limit) - if hits: - return [(k, i, s, sc, "fts5") for k, i, s, sc in hits] - except sqlite3.Error: - # FTS5 unavailable, db missing, or schema mismatch — fall through - # to substring scan. Other exceptions are real bugs and propagate. - pass + backend = _configured_backend(store) + + if backend in ("auto", "embedding"): + raw = index_db.search_semantic(store.kb_dir, query, limit=limit) + if raw: + return [(k, i, s, sc, "embedding") for k, i, s, sc in raw] + if backend == "embedding": + return [] + + if backend in ("auto", "fts5"): + try: + hits = index_db.search(store.kb_dir, query, limit=limit) + if hits: + return [(k, i, s, sc, "fts5") for k, i, s, sc in hits] + except sqlite3.Error: + # FTS5 unavailable, db missing, or schema mismatch — fall through + # to substring scan (auto) or empty (explicit fts5). Other + # exceptions are real bugs and propagate. + pass + if backend == "fts5": + return [] + return [ (k, i, s, sc, "substring") for k, i, s, sc in store.search_substring(query, limit=limit) diff --git a/src/vouch/storage.py b/src/vouch/storage.py index 797f9a11..a67bf6f7 100644 --- a/src/vouch/storage.py +++ b/src/vouch/storage.py @@ -69,7 +69,9 @@ def _starter_config() -> dict[str, Any]: "version": 1, "review": {"require_human_approval": True}, "retrieval": { - "backends": ["fts5", "substring"], + # auto = embedding -> fts5 -> substring; or pin one of + # embedding | fts5 | substring. See context._retrieve. + "backend": "auto", "default_limit": 10, }, "agents": { diff --git a/tests/test_retrieval_backend.py b/tests/test_retrieval_backend.py new file mode 100644 index 00000000..2dc30cd6 --- /dev/null +++ b/tests/test_retrieval_backend.py @@ -0,0 +1,101 @@ +"""`context._retrieve` honors `retrieval.backend` in config.yaml (#92). + +These tests monkeypatch `index_db.search_semantic` so they exercise the +dispatch logic without needing the optional embeddings extras (numpy / +sentence-transformers), and therefore run under the base CI install. +""" + +from __future__ import annotations + +from pathlib import Path + +import pytest +import yaml + +from vouch import context, health +from vouch.models import Claim +from vouch.storage import KBStore + + +@pytest.fixture +def store(tmp_path: Path) -> KBStore: + s = KBStore.init(tmp_path) + src = s.put_source(b"e") + s.put_claim(Claim(id="c1", text="JWT token rotation", evidence=[src.id])) + health.rebuild_index(s) + return s + + +def _set_backend(store: KBStore, backend: str) -> None: + cfg = yaml.safe_load(store.config_path.read_text()) + cfg.setdefault("retrieval", {})["backend"] = backend + store.config_path.write_text(yaml.safe_dump(cfg)) + + +def _force_semantic_hit(monkeypatch: pytest.MonkeyPatch) -> None: + """Make the embedding path always return a hit, so a backend label of + "embedding" appears iff `_retrieve` actually consulted semantic search.""" + monkeypatch.setattr( + context.index_db, "search_semantic", + lambda *a, **k: [("claim", "c1", "JWT token rotation", 0.99)], + ) + + +def _backends(pack: dict) -> set[str]: + return {item["backend"] for item in pack["items"]} + + +def test_backend_fts5_skips_embedding( + store: KBStore, monkeypatch: pytest.MonkeyPatch +) -> None: + """Regression for #92: with retrieval.backend=fts5, the embedding path + must not run even when it would return hits.""" + _force_semantic_hit(monkeypatch) + _set_backend(store, "fts5") + pack = context.build_context_pack(store, query="JWT") + assert pack["items"] + assert "embedding" not in _backends(pack) + assert _backends(pack) <= {"fts5", "substring"} + + +def test_backend_embedding_is_recognized( + store: KBStore, monkeypatch: pytest.MonkeyPatch +) -> None: + """`embedding` is an accepted value and forces the semantic path.""" + _force_semantic_hit(monkeypatch) + _set_backend(store, "embedding") + pack = context.build_context_pack(store, query="JWT") + assert pack["items"] + assert _backends(pack) == {"embedding"} + + +def test_backend_substring_only( + store: KBStore, monkeypatch: pytest.MonkeyPatch +) -> None: + _force_semantic_hit(monkeypatch) + _set_backend(store, "substring") + pack = context.build_context_pack(store, query="JWT") + assert pack["items"] + assert _backends(pack) == {"substring"} + + +def test_backend_auto_prefers_embedding( + store: KBStore, monkeypatch: pytest.MonkeyPatch +) -> None: + """Default `auto` tries embedding first when it returns hits.""" + _force_semantic_hit(monkeypatch) + _set_backend(store, "auto") + pack = context.build_context_pack(store, query="JWT") + assert any(item["backend"] == "embedding" for item in pack["items"]) + + +def test_unset_backend_defaults_to_auto( + store: KBStore, monkeypatch: pytest.MonkeyPatch +) -> None: + """A config with no retrieval.backend behaves like `auto`.""" + _force_semantic_hit(monkeypatch) + cfg = yaml.safe_load(store.config_path.read_text()) + cfg.get("retrieval", {}).pop("backend", None) + store.config_path.write_text(yaml.safe_dump(cfg)) + pack = context.build_context_pack(store, query="JWT") + assert any(item["backend"] == "embedding" for item in pack["items"]) From f57917c14feb3d009c843f1e9135fa117b03c7de Mon Sep 17 00:00:00 2001 From: dripsmvcp <138900956+dripsmvcp@users.noreply.github.com> Date: Tue, 26 May 2026 16:11:34 +0900 Subject: [PATCH 016/149] feat(cli): vouch approve accepts multiple ids for scriptable bulk approval (#93) --- CHANGELOG.md | 8 +++++ src/vouch/cli.py | 61 +++++++++++++++++++++++++++++++----- src/vouch/proposals.py | 61 ++++++++++++++++++++++++++---------- tests/test_cli.py | 71 ++++++++++++++++++++++++++++++++++++++++++ 4 files changed, 178 insertions(+), 23 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 92d5cc73..2c8f92a2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,14 @@ All notable changes to vouch are documented here. Format follows ## [Unreleased] ### Added +- `vouch approve …` approves multiple proposals in one + non-interactive call for CI and backlog clearing (#93). Default is + all-or-nothing: every id is validated as an approvable pending proposal + before any is written, so a typo or already-decided id aborts the batch + without approving anything. `--keep-going` switches to best-effort + (approve what you can, report the rest, exit non-zero on partial failure). + One audit event is still recorded per approved artifact. Complements the + interactive `vouch review` queue. - `vouch sync-check` and `vouch sync-apply` reconcile another `.vouch` directory or bundle by importing only non-conflicting durable artifacts and reporting conflicts without overwriting reviewed knowledge. diff --git a/src/vouch/cli.py b/src/vouch/cli.py index 02210b2d..d98b2dc8 100644 --- a/src/vouch/cli.py +++ b/src/vouch/cli.py @@ -32,6 +32,7 @@ from .onboarding import seed_starter_kb from .proposals import ( ProposalError, + check_approvable, propose_claim, propose_entity, propose_page, @@ -322,14 +323,60 @@ def show(proposal_id: str) -> None: @cli.command() -@click.argument("proposal_id") +@click.argument("proposal_ids", nargs=-1, required=True) @click.option("--reason", default=None) -def approve(proposal_id: str, reason: str | None) -> None: - """Approve a proposal — converts it into a durable artifact.""" - store = _load_store() - with _cli_errors(): - artifact = do_approve(store, proposal_id, approved_by=_whoami(), reason=reason) - click.echo(f"Approved → {type(artifact).__name__.lower()}/{artifact.id}") +@click.option( + "--keep-going", is_flag=True, + help="Best-effort: approve every id that can be approved and report the " + "rest, instead of the default all-or-nothing precheck.", +) +def approve(proposal_ids: tuple[str, ...], reason: str | None, keep_going: bool) -> None: + """Approve one or more proposals — converts each into a durable artifact. + + Pass several ids to approve a batch in one call (useful for CI and + clearing a review backlog). One audit event is recorded per approved + artifact. + + Semantics: + + \b + - default (all-or-nothing): every id is validated as an approvable + pending proposal before any is written; a typo or already-decided id + aborts the whole batch and nothing is approved. + - --keep-going (best-effort): approve each id independently, report the + failures, and exit non-zero if any failed. + """ + store = _load_store() + approver = _whoami() + + if not keep_going: + blocked = [ + (pid, reason_blocked) + for pid in proposal_ids + if (reason_blocked := check_approvable(store, pid, approved_by=approver)) + ] + if blocked: + for pid, why in blocked: + click.echo(f"✗ {pid}: {why}", err=True) + raise click.ClickException( + f"refusing to approve: {len(blocked)} of {len(proposal_ids)} not " + "approvable — nothing was approved (use --keep-going for best-effort)" + ) + + failures = 0 + for pid in proposal_ids: + try: + artifact = do_approve(store, pid, approved_by=approver, reason=reason) + except (ArtifactNotFoundError, ValueError, ProposalError, LifecycleError) as e: + failures += 1 + click.echo(f"✗ {pid}: {e}", err=True) + continue + click.echo(f"Approved → {type(artifact).__name__.lower()}/{artifact.id}") + + if failures: + raise click.ClickException( + f"{failures} of {len(proposal_ids)} proposal(s) failed to approve" + ) @cli.command() diff --git a/src/vouch/proposals.py b/src/vouch/proposals.py index dbca9260..47592e71 100644 --- a/src/vouch/proposals.py +++ b/src/vouch/proposals.py @@ -210,23 +210,17 @@ def propose_relation( # --- decisions ------------------------------------------------------------ -def approve( - store: KBStore, - proposal_id: str, - *, - approved_by: str, - reason: str | None = None, -) -> Claim | Page | Entity | Relation: - """Approve a pending proposal and write it as a durable artifact. - - Raises ProposalError if the proposal is not pending or if - approved_by matches proposed_by (forbidden_self_approval). +def _approval_block_reason( + store: KBStore, proposal: Proposal, approved_by: str +) -> str | None: + """Why `approved_by` cannot approve `proposal` right now, or None. + + Covers the deterministic pre-write gates — not-pending and + forbidden_self_approval. Shared by `approve()` and `check_approvable()` + so the single-approve path and the batch CLI's precheck never drift. """ - proposal = store.get_proposal(proposal_id) if proposal.status != ProposalStatus.PENDING: - raise ProposalError( - f"proposal {proposal_id} is {proposal.status.value}, not pending" - ) + return f"proposal {proposal.id} is {proposal.status.value}, not pending" if approved_by == proposal.proposed_by: cfg: dict[str, Any] = {} try: @@ -241,10 +235,45 @@ def approve( review_cfg.get("approver_role") if isinstance(review_cfg, dict) else None ) if approver_role != "trusted-agent": - raise ProposalError( + return ( f"forbidden_self_approval: {approved_by} cannot approve their own " "proposal (set review.approver_role: trusted-agent in config.yaml to opt out)" ) + return None + + +def check_approvable( + store: KBStore, proposal_id: str, *, approved_by: str +) -> str | None: + """Return why `proposal_id` can't be approved by `approved_by`, or None. + + Read-only. `None` means the deterministic gates pass; the actual write in + `approve()` can still fail on a pre-existing artifact or an I/O error. + Used by the batch CLI to validate a whole set before mutating anything. + """ + try: + proposal = store.get_proposal(proposal_id) + except ArtifactNotFoundError: + return f"proposal {proposal_id} not found" + return _approval_block_reason(store, proposal, approved_by) + + +def approve( + store: KBStore, + proposal_id: str, + *, + approved_by: str, + reason: str | None = None, +) -> Claim | Page | Entity | Relation: + """Approve a pending proposal and write it as a durable artifact. + + Raises ProposalError if the proposal is not pending or if + approved_by matches proposed_by (forbidden_self_approval). + """ + proposal = store.get_proposal(proposal_id) + block = _approval_block_reason(store, proposal, approved_by) + if block: + raise ProposalError(block) payload = dict(proposal.payload) # Refuse to overwrite an existing artifact. Without this guard a retry # after a crash between put_() and move_proposal_to_decided() would diff --git a/tests/test_cli.py b/tests/test_cli.py index 813c901b..cdef295e 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -309,3 +309,74 @@ def _side_effect(store, proposal_id, **kwargs): assert result.exit_code == 0 assert "warning:" in result.stderr assert "1/2 proposal(s) failed" in result.stderr + + +# --- batch approval (#93) ------------------------------------------------- + + +def _propose_n(store: KBStore, n: int) -> list[str]: + src = store.put_source(b"e") + ids = [] + for i in range(n): + pr = propose_claim( + store, text=f"batch claim {i}", evidence=[src.id], proposed_by="agent" + ) + ids.append(pr.id) + return ids + + +def test_approve_batch_approves_all( + store: KBStore, monkeypatch: pytest.MonkeyPatch +) -> None: + monkeypatch.delenv("VOUCH_AGENT", raising=False) + ids = _propose_n(store, 3) + result = CliRunner().invoke(cli, ["approve", *ids]) + assert result.exit_code == 0, result.output + pending = {p.id for p in store.list_proposals(ProposalStatus.PENDING)} + for pid in ids: + assert pid not in pending + assert result.output.count("Approved") == 3 + + +def test_approve_batch_one_audit_event_per_artifact( + store: KBStore, monkeypatch: pytest.MonkeyPatch +) -> None: + from vouch import audit + monkeypatch.delenv("VOUCH_AGENT", raising=False) + ids = _propose_n(store, 2) + CliRunner().invoke(cli, ["approve", *ids]) + approve_events = [ + e for e in audit.read_events(store.kb_dir) + if e.event.endswith(".approve") + ] + assert len(approve_events) == 2 + + +def test_approve_batch_atomic_aborts_on_bad_id( + store: KBStore, monkeypatch: pytest.MonkeyPatch +) -> None: + """Default is all-or-nothing: one bad id approves nothing.""" + monkeypatch.delenv("VOUCH_AGENT", raising=False) + good = _propose_n(store, 2) + result = CliRunner().invoke(cli, ["approve", good[0], "no-such-id", good[1]]) + assert result.exit_code != 0, result.output + assert "Traceback" not in result.output + # Nothing approved — both good proposals are still pending. + for cid in good: + assert cid in {p.id for p in store.list_proposals(ProposalStatus.PENDING)} + + +def test_approve_batch_keep_going_best_effort( + store: KBStore, monkeypatch: pytest.MonkeyPatch +) -> None: + """--keep-going approves what it can and exits non-zero on partial failure.""" + monkeypatch.delenv("VOUCH_AGENT", raising=False) + good = _propose_n(store, 2) + result = CliRunner().invoke( + cli, ["approve", "--keep-going", good[0], "no-such-id", good[1]] + ) + assert result.exit_code != 0, result.output + # Both valid proposals were approved despite the bad id in the middle. + pending = {p.id for p in store.list_proposals(ProposalStatus.PENDING)} + assert good[0] not in pending + assert good[1] not in pending From 8d343105a78cb74a40d393fc0c3ff92a2bc68950 Mon Sep 17 00:00:00 2001 From: dripsmvcp <138900956+dripsmvcp@users.noreply.github.com> Date: Tue, 26 May 2026 16:15:58 +0900 Subject: [PATCH 017/149] docs(proposals): add VEP-0004 HTTP transport (draft) (#94) --- proposals/README.md | 1 + proposals/VEP-0004-http-transport.md | 220 +++++++++++++++++++++++++++ 2 files changed, 221 insertions(+) create mode 100644 proposals/VEP-0004-http-transport.md diff --git a/proposals/README.md b/proposals/README.md index b45c0d17..a7f90c6f 100644 --- a/proposals/README.md +++ b/proposals/README.md @@ -59,6 +59,7 @@ supersedes them. | [0001](VEP-0001-review-gate.md) | Review gate | final | 0.0.1 | | [0002](VEP-0002-jsonl-transport.md) | JSONL transport | final | 0.0.1 | | [0003](VEP-0003-content-hashed-sources.md) | Content-hashed sources | final | 0.0.1 | +| [0004](VEP-0004-http-transport.md) | HTTP transport | draft | — | ## Numbering diff --git a/proposals/VEP-0004-http-transport.md b/proposals/VEP-0004-http-transport.md new file mode 100644 index 00000000..45a01b0d --- /dev/null +++ b/proposals/VEP-0004-http-transport.md @@ -0,0 +1,220 @@ +--- +vep: 0004 +title: HTTP transport +author: dripsmvcp +status: draft +created: 2026-05-26 +landed-in: "" +supersedes: [] +superseded-by: "" +--- + +# VEP-0004: HTTP transport + +## Summary + +Add an HTTP transport (`vouch serve --transport http`) alongside the +existing stdio (MCP) and JSONL transports. Same `kb.*` method surface; +one long-lived process that multiple clients can connect to over the +network instead of each spawning a local subprocess. Binds `127.0.0.1` +by default; a bearer token is required before it will bind any +non-loopback address. + +## Motivation + +`vouch serve` today is stdio-only (MCP over stdin/stdout) or JSONL over +stdin/stdout. Both require the client to *be* the parent process — every +consumer launches its own `vouch` subprocess and owns its lifetime. That +is the right model for a single LLM host on one machine, and the wrong +model for: + +- **Multiple clients sharing one KB.** Two editors, a CI job, and a + dashboard all want the same `.vouch/`. Today that's four subprocesses, + each re-opening `state.db`. +- **A hosted / self-hosted deployment.** Issue #94 wants a remote option + so a team (and a future Claude plugin) can point at a URL instead of + shipping the CLI to every machine. +- **Anything that already speaks HTTP.** A `curl` one-liner or a serverless + function shouldn't need a pseudo-tty and a subprocess just to call + `kb.search`. + +ROADMAP 0.1 lists this and marks it **[VEP]**; VEP-0002 (JSONL transport) +explicitly deferred HTTP to "0.1 ... adds bind/auth concerns. Real plan +for 0.1." This is that plan. + +## Proposal + +Add `http` to the `--transport` choice on `vouch serve`, plus binding and +auth options: + +``` +vouch serve --transport http + [--host 127.0.0.1] # default loopback + [--port 8731] + [--token ] # or env VOUCH_HTTP_TOKEN + [--allow-public] # required to bind a non-loopback host +``` + +The method surface is unchanged: every `kb.*` method already reachable +over MCP and JSONL is reachable over HTTP, with the **same parameter and +result shapes** defined in [spec/methods.md](../spec/methods.md). No new +methods, no renamed methods, no changed parameter shapes. + +### Endpoints + +| Method & path | Body / result | +|----------------------|------------------------------------------------------------| +| `POST /rpc` | JSONL envelope in, JSONL envelope out (identical to VEP-0002) | +| `GET /capabilities` | `kb.capabilities` JSON (unauthenticated; advertises the surface) | +| `GET /healthz` | `{"ok": true}` liveness probe (unauthenticated) | + +`POST /rpc` is the whole surface. Request and response envelopes are +byte-for-byte the JSONL envelopes from VEP-0002: + +```json +// request +{"id": "r1", "method": "kb.search", "params": {"query": "jwt"}} +// response +{"id": "r1", "ok": true, "result": {...}} +{"id": "r1", "ok": false, "error": {"code": "missing_param", "message": "..."}} +``` + +`kb.capabilities.transports` gains `"http"` when the HTTP server is the +one answering (the array reflects *reachable* transports). + +## Design + +The MCP and JSONL servers already route through a single internal +dispatch table (VEP-0002: `_handle_request(method, params, actor) -> +Result | Error`). The HTTP transport is a third front-end over that same +function — no business logic is duplicated. + +``` +src/vouch/http_server.py + run_http(host, port, *, token, allow_public) -> None + - refuse to start if host is non-loopback and token is None + (unless --allow-public AND token both set) + - http.server.ThreadingHTTPServer + a BaseHTTPRequestHandler + - POST /rpc: + auth check (see below) -> + body = json.loads(rfile.read(Content-Length)) -> + _handle_request(body["method"], body.get("params", {}), actor) -> + write {id, ok, result|error} + - GET /capabilities, GET /healthz: no auth +``` + +- **Zero new runtime dependencies.** Uses the stdlib `http.server` + (`ThreadingHTTPServer`). No Flask/FastAPI/uvicorn. (Open question below + on whether to adopt the MCP streamable-HTTP transport instead.) +- **Actor attribution.** The `X-Vouch-Agent` request header maps to the + audit `actor`, mirroring how `VOUCH_AGENT` works for stdio/JSONL. Absent + header → `unknown-agent`, exactly as the other transports default. +- **Concurrency.** `ThreadingHTTPServer` serves requests on threads. + Writes already go through the file-backed store with exclusive-create + semantics and SQLite's own locking; the review gate is unchanged. We + document that a single KB behind multiple writers relies on those + existing guarantees and add a smoke test for concurrent `kb.search`. +- **Wiring.** `cli.py serve` gains the `http` branch: + `from .http_server import run_http; run_http(host, port, token=..., allow_public=...)`. + +### Auth model + +| Bind | Token required? | Rationale | +|------------------------------|-----------------|--------------------------------------------| +| `127.0.0.1` / `::1` (default)| No | Same trust boundary as JSONL: same machine | +| any non-loopback | **Yes** | Refuse to start without `--allow-public` + a token | + +When a token is configured, every `POST /rpc` must send +`Authorization: Bearer `; comparison is constant-time +(`hmac.compare_digest`). `GET /capabilities` and `/healthz` are always +unauthenticated (they leak only the method list and liveness). + +## Compatibility + +- **`.vouch/` layout:** unchanged. No migration. +- **Bundle format / audit-log shape:** unchanged. +- **`kb.capabilities`:** `transports` array gains `"http"` when served over + HTTP — additive; existing consumers that read the array keep working. +- **Method surface:** unchanged. CI's existing surface-parity test + (`tests/test_capabilities.py`, which asserts every advertised method is + reachable on every transport) is extended to cover HTTP. +- **Default behavior:** unchanged. `vouch serve` with no `--transport` is + still stdio/MCP. HTTP is strictly opt-in. + +## Security implications + +This adds a network trust boundary, so it gets the most scrutiny. + +- **Loopback by default.** Out of the box the server is unreachable off + the host — same exposure as JSONL. Binding `0.0.0.0` (or any + non-loopback address) is refused unless the operator passes both + `--allow-public` and a token. "Accidentally exposed an unauthenticated + KB to the LAN" should be impossible by default. +- **The review gate still applies.** HTTP clients file proposals like any + other transport; `kb.approve` over HTTP is the same privileged operation + it is everywhere and the `forbidden_self_approval` guard is unchanged. A + network attacker who reaches `/rpc` with a valid token can approve + proposals — so the token is a write-gate credential, documented as such. +- **No TLS in v1.** The stdlib server speaks plaintext HTTP. Public + deployments MUST terminate TLS at a reverse proxy (nginx/Caddy). We + document this rather than shipping a half-baked in-process TLS story. +- **CORS denied.** No `Access-Control-Allow-Origin` header — browsers + can't cross-origin call the KB. Prevents a malicious page from driving a + developer's loopback server. +- **Token handling.** Read from `--token` or `VOUCH_HTTP_TOKEN`; never + logged, never echoed into the audit log. Constant-time comparison. +- **Out of scope for v1 (documented as such):** rate limiting, request + size caps beyond a sane `Content-Length` ceiling, per-method authz + (read-only vs write tokens), and audit of auth failures. Flagged as + follow-ups so reviewers can decide what's blocking. + +## Performance implications + +Not on a hot path for the common (stdio) case — this is a new, opt-in +front-end. For HTTP itself: one `json.loads` + one dispatch per request, +identical cost to JSONL plus HTTP framing. `ThreadingHTTPServer` is fine +for the expected scale (a handful of clients against one KB); it is not a +high-throughput server and we don't claim it is. `state.db` is opened per +the existing store semantics; no new caching is introduced. + +## Open questions + +- **Bespoke REST vs MCP streamable-HTTP.** The issue mentions "a future + Claude plugin." MCP defines a streamable-HTTP transport; a plugin may + prefer that over a vouch-specific `POST /rpc`. Should v1 ship the simple + JSONL-over-HTTP endpoint (this proposal), the MCP streamable-HTTP + transport, or both? Leaning JSONL-over-HTTP first (zero deps, matches the + existing envelope) with MCP-HTTP as a later VEP if a plugin needs it. +- **Default port.** `8731` is a placeholder. Pick something unlikely to + collide; document it. +- **Per-method authz.** Should read-only methods be reachable with a + weaker (or no) token while writes require the full token? Deferred unless + reviewers want it in v1. +- **Config vs flags.** Should `host`/`port`/`token` also be settable under + a `serve:` block in `config.yaml`? (That would be a `config.yaml` + semantics change and might warrant its own note.) + +## Alternatives considered + +- **Wrap JSONL in your own listener (status quo).** VEP-0002 explicitly + says the JSONL transport has no auth and "if someone wraps it in a + network listener, they need to add their own authentication." That works + but pushes the bind/auth/exposure story onto every user and gives no + documented, safe-by-default option. Issue #94 asks for first-class + support precisely to avoid that. +- **A web framework (FastAPI/Flask + uvicorn).** Nicer ergonomics, but + drags real dependencies into a project that has kept its runtime + surface deliberately small. The stdlib server is enough for the scale. +- **MCP streamable-HTTP only.** Tighter fit for LLM hosts, but heavier to + implement and wrong for the `curl`/CI/script consumers that motivated + JSONL in the first place. Best handled as a follow-up VEP if demand + appears — see open questions. +- **TLS in-process.** Rejected for v1: certificate handling in the CLI is + a footgun; reverse-proxy TLS termination is the boring, correct answer. + +## References + +- Issue [#94](https://github.com/vouchdev/vouch/issues/94) +- [VEP-0002: JSONL transport](VEP-0002-jsonl-transport.md) +- [spec/transports.md](../spec/transports.md) +- [ROADMAP.md](../ROADMAP.md) — 0.1 line item, marked [VEP] From 0dba23287f6d9f8fb5a036a7c344793734f5bd92 Mon Sep 17 00:00:00 2001 From: dripsmvcp <138900956+dripsmvcp@users.noreply.github.com> Date: Tue, 26 May 2026 16:22:18 +0900 Subject: [PATCH 018/149] docs(proposals): add VEP-0005 richer scopes on Claim/Source (draft) (#100) --- proposals/README.md | 1 + proposals/VEP-0005-richer-scopes.md | 225 ++++++++++++++++++++++++++++ 2 files changed, 226 insertions(+) create mode 100644 proposals/VEP-0005-richer-scopes.md diff --git a/proposals/README.md b/proposals/README.md index b45c0d17..c1735320 100644 --- a/proposals/README.md +++ b/proposals/README.md @@ -59,6 +59,7 @@ supersedes them. | [0001](VEP-0001-review-gate.md) | Review gate | final | 0.0.1 | | [0002](VEP-0002-jsonl-transport.md) | JSONL transport | final | 0.0.1 | | [0003](VEP-0003-content-hashed-sources.md) | Content-hashed sources | final | 0.0.1 | +| [0005](VEP-0005-richer-scopes.md) | Richer scopes on Claim/Source | draft | — | ## Numbering diff --git a/proposals/VEP-0005-richer-scopes.md b/proposals/VEP-0005-richer-scopes.md new file mode 100644 index 00000000..83750f7c --- /dev/null +++ b/proposals/VEP-0005-richer-scopes.md @@ -0,0 +1,225 @@ +--- +vep: 0005 +title: Richer scopes on Claim/Source +author: dripsmvcp +status: draft +created: 2026-05-26 +landed-in: "" +supersedes: [] +superseded-by: "" +--- + +# VEP-0005: Richer scopes on Claim/Source + +## Summary + +Replace the single `scope` enum on `Claim` and `Source` with a +structured scope of `(visibility, project, agent)`, so a KB shared +across agents and projects can express *who-sees-what*. Out-of-scope +artifacts are filtered out of retrieval (`kb.search`, `kb.context`). +Existing KBs keep working unchanged: a bare `scope: project` string +still parses, and the default viewer context behaves like today. + +## Motivation + +`Claim` and `Source` each carry a single `scope: Scope` field today +(`src/vouch/models.py:141,195`), where `Scope` is a flat visibility tier: + +```python +class Scope(StrEnum): + PRIVATE = "private" + PROJECT = "project" + TEAM = "team" + PUBLIC = "public" +``` + +That answers "how widely visible" but not "*which* project" or "*which* +agent." With deterministic multi-agent sync now landed (#90 / #91), a +single `.vouch/` gets populated by several agents working on several +projects. The moment two projects share a KB, retrieval can't tell them +apart: `vouch context "auth"` for project A surfaces project B's claims, +and one agent's `private` scratch claims are indistinguishable from +another's because `private` has no owner. + +Concretely, today you cannot say: + +> "This claim belongs to project `billing`, was authored by agent +> `claude-cli`, and should only show up in that project's context." + +ROADMAP 0.2 lists richer scopes and marks it **[VEP]**. + +## Proposal + +Introduce a structured scope object and use it on `Claim` and `Source`. +The existing flat enum is renamed `Visibility` (its values are +unchanged) and becomes one field of the new object: + +```python +class Visibility(StrEnum): # was: Scope (values unchanged) + PRIVATE = "private" + PROJECT = "project" + TEAM = "team" + PUBLIC = "public" + +class ArtifactScope(BaseModel): + visibility: Visibility = Visibility.PROJECT + project: str | None = None # None = not bound to a specific project + agent: str | None = None # None = not bound to a specific agent +``` + +`Claim.scope` and `Source.scope` become +`scope: ArtifactScope = Field(default_factory=ArtifactScope)`. + +On-disk YAML changes from: + +```yaml +scope: project +``` + +to: + +```yaml +scope: + visibility: project + project: billing + agent: claude-cli +``` + +### Retrieval filtering + +Retrieval gains a **viewer context** `(project, agent)`, supplied (in +priority order) by an explicit request param, then `VOUCH_PROJECT` / +`VOUCH_AGENT` env, then a `retrieval.scope` block in `config.yaml`, +else empty. An artifact is *visible* to a viewer iff: + +| visibility | visible when … | +|------------|-------------------------------------------------------------| +| `public` | always | +| `team` | always (team-wide within this KB) | +| `project` | `scope.project is None` **or** `scope.project == viewer.project` | +| `private` | `scope.agent == viewer.agent` (owner only) | + +`_retrieve` (in `context.py`) and the `kb.search` handlers apply this +filter to hits before returning. The **default** viewer context (no +project, no agent) sees `public` + `team` + unbound `project` artifacts +and hides every `private` and every project-bound artifact owned by a +*different* project — chosen so a fresh single-agent KB behaves exactly +as it does today (everything defaults to `project` with `project=None`, +which stays visible). + +## Design + +- **Models** (`models.py`): rename `Scope` → `Visibility`; add + `ArtifactScope`; switch `Claim.scope` / `Source.scope` to it. Keep a + module-level `Scope = Visibility` alias for one release to avoid + breaking imports. +- **Back-compat parsing**: a `field_validator("scope", mode="before")` + on both models coerces a bare string (the old form) into + `{"visibility": }`. So every existing `claims/*.yaml` and + `sources/*/meta.yaml` reads without a migration step. +- **Viewer context** helper, e.g. `scoping.viewer_from(config, params, + env) -> ArtifactScope`-like `(project, agent)`; one place computes it + for all transports. +- **Filtering** lives as a post-retrieval pass first (`[h for h in hits + if visible(h.scope, viewer)]`) — simple, transport-agnostic, and easy + to test. A later optimization can push project/visibility into the + SQL `WHERE` in `index_db` if profiling warrants (see Performance). +- **Config**: optional `retrieval.scope: {project, agent}` in + `config.yaml`. (This is a `config.yaml` semantics addition — called + out under Compatibility.) +- **Capabilities**: add a `scoping` flag/section to `kb.capabilities` + so clients can detect that this server filters by scope. + +## Compatibility + +- **Existing `.vouch/` directories**: read unchanged thanks to the + string→object validator. On next write the artifact is re-emitted in + the new object form (lazy migration); a `vouch migrate` pass can + rewrite eagerly but is not required. +- **Bundle format**: bundles produced after this change carry the scope + *object*. An older vouch importing a new bundle would see an object + where it expects a string and fail schema validation — a + forward-incompatibility. Proposal: bump the bundle `spec` version and + have new importers accept both forms (the validator already does). +- **`kb.capabilities` shape**: additive (`scoping` section). Existing + consumers are unaffected. +- **JSON Schemas** (`schemas/`) regenerate: `claim`/`source` `scope` + changes from an enum string to an object. Downstream schema consumers + must update. +- **`kb.*` method surface**: unchanged method *names*; `kb.search` / + `kb.context` gain optional `project` / `agent` params (additive, + defaulted), and results may now be *filtered* — a behavior change + reviewers should weigh (a viewer with a project set sees fewer hits). + +## Security implications + +**Scope is a retrieval/relevance filter, not an access-control or +confidentiality boundary, and the VEP must say so loudly.** A `.vouch/` +is plaintext YAML/Markdown in (usually) a git repo. Anyone who can read +the directory reads every artifact regardless of scope — +`scope.visibility = private` means "excluded from other agents' default +context," **not** "secret" or "encrypted." Treating `private` as a +secret store would be a serious misuse. + +Implications: + +- No new attack surface on the review gate or audit log — scope does not + gate writes or approvals, only what retrieval *returns*. +- Filtering must **fail closed for `private`**: a missing/empty viewer + agent must not match someone else's `private` artifact (default viewer + has `agent=None`, and `None == "someone"` is false — good), but this + needs an explicit test so a future refactor can't flip it to fail-open. +- Sync (#90/#91) must not be assumed to respect scope as a trust + boundary across machines — it copies files, and the files carry their + contents in the clear. Documented in `spec/`. + +## Performance implications + +Post-retrieval filtering is `O(hits)` with a cheap per-hit comparison — +negligible against the search itself. The only artifacts whose scope +must be known are the ones already fetched. If a future workload makes +this hot (e.g. a viewer scoped to a tiny project in a huge KB, where we +over-fetch then discard most hits), push `visibility`/`project` into the +`embedding_index` / FTS rows and filter in SQL. Out of scope for v1. + +## Open questions + +- **`team` semantics.** With one KB == one team, `team` and a + project-unbound `project` are nearly identical. Is `team` worth keeping + distinct from `public` here, or does it only matter once cross-KB sync + has a notion of "team"? Leaning: keep the enum value, treat + team-wide == KB-wide for now. +- **Where to filter.** Post-retrieval pass (proposed) vs SQL `WHERE` in + `index_db`. Post-pass is simpler and transport-uniform; SQL is faster + at scale. Start with the pass? +- **Viewer-context source of truth.** Request param vs env + (`VOUCH_PROJECT`/`VOUCH_AGENT`) vs `config.yaml` — proposed precedence + is param > env > config. Agree? +- **Entities / relations / pages.** The issue scopes only Claim and + Source. Do Entity/Relation/Page need scope too (a project-scoped page + leaking into another project's context is the same problem)? Propose + deferring to a follow-up VEP unless reviewers want it now. +- **Sync interaction.** Should `vouch sync-apply` partition by project + scope, or import everything and rely on retrieval filtering? (#90/#91.) + +## Alternatives considered + +- **Keep the single enum, use `tags` for project/agent.** Tags already + exist and need no schema change. Rejected: untyped, unvalidated, and + every consumer would reinvent the "is this tag a project?" convention; + filtering would be string-soup. A typed field is the point. +- **ACLs / encryption per scope.** Wrong model for a git-backed plaintext + KB and a large surface; confidentiality isn't what this issue asks for. +- **Per-project subdirectories under `.vouch/`.** Physically partitions + artifacts, but it's a much bigger on-disk-layout change, complicates + content-addressing and sync, and doesn't express `visibility` or + `agent`. Rejected for v1. +- **A free-form `scope: dict`.** Maximum flexibility, zero validation. + Rejected for the same reason as tags. + +## References + +- Issue [#100](https://github.com/vouchdev/vouch/issues/100) +- Deterministic sync: #90 / #91 (the change that makes this matter) +- [VEP-0001: Review gate](VEP-0001-review-gate.md) — unaffected by scope +- [ROADMAP.md](../ROADMAP.md) — 0.2 line item, marked [VEP] From a5f074f754a2005c0eca8db636a00943ccb823d1 Mon Sep 17 00:00:00 2001 From: dripsmvcp <138900956+dripsmvcp@users.noreply.github.com> Date: Tue, 26 May 2026 16:50:22 +0900 Subject: [PATCH 019/149] feat(cli): colourise output, add --json to lint/search, progress on long ops (#54) --- CHANGELOG.md | 7 ++ src/vouch/bundle.py | 11 ++- src/vouch/cli.py | 143 ++++++++++++++++++++++++++++++++------- src/vouch/health.py | 32 +++++++-- tests/test_cli_output.py | 105 ++++++++++++++++++++++++++++ 5 files changed, 269 insertions(+), 29 deletions(-) create mode 100644 tests/test_cli_output.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 92d5cc73..34edb808 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,13 @@ All notable changes to vouch are documented here. Format follows ## [Unreleased] ### Added +- Friendlier CLI output (#54, track 2): colourised `vouch status` / `lint` / + `doctor` / `search` (honours `NO_COLOR`, `FORCE_COLOR`, and TTY detection); + `--json` on `vouch lint` and `vouch search` for machine-readable output + while the default stays human-readable; progress callbacks on the long ops + (`rebuild_index`, `doctor`, bundle `export`/`import_apply`) surfaced as + status lines on interactive terminals; and `vouch index` / `vouch export` + now report a clean `Error:` instead of a traceback on a malformed artifact. - `vouch sync-check` and `vouch sync-apply` reconcile another `.vouch` directory or bundle by importing only non-conflicting durable artifacts and reporting conflicts without overwriting reviewed knowledge. diff --git a/src/vouch/bundle.py b/src/vouch/bundle.py index a0595464..7a47a185 100644 --- a/src/vouch/bundle.py +++ b/src/vouch/bundle.py @@ -21,6 +21,7 @@ import io import json import tarfile +from collections.abc import Callable from dataclasses import dataclass from pathlib import Path from typing import Any @@ -101,11 +102,16 @@ def build_manifest(kb_dir: Path) -> dict[str, Any]: } -def export(kb_dir: Path, *, dest: Path, actor: str = "vouch-export") -> dict[str, Any]: +def export( + kb_dir: Path, *, dest: Path, actor: str = "vouch-export", + on_progress: Callable[[str], None] | None = None, +) -> dict[str, Any]: manifest = build_manifest(kb_dir) dest.parent.mkdir(parents=True, exist_ok=True) with tarfile.open(dest, "w:gz") as tar: for rel, abs_path in _iter_export_files(kb_dir): + if on_progress is not None: + on_progress(rel.as_posix()) tar.add(abs_path, arcname=rel.as_posix()) manifest_bytes = json.dumps(manifest, indent=2, sort_keys=True).encode() info = tarfile.TarInfo(MANIFEST_NAME) @@ -292,6 +298,7 @@ def import_apply( *, on_conflict: str = "skip", actor: str = "vouch-import", + on_progress: Callable[[str], None] | None = None, ) -> dict[str, Any]: """Apply a bundle. `on_conflict` ∈ {"skip", "overwrite", "fail"}. @@ -347,6 +354,8 @@ def import_apply( continue dest.write_bytes(body) written.append(member.name) + if on_progress is not None: + on_progress(member.name) result = { "bundle_id": check.bundle_id, "written": written, diff --git a/src/vouch/cli.py b/src/vouch/cli.py index 02210b2d..2944d4b4 100644 --- a/src/vouch/cli.py +++ b/src/vouch/cli.py @@ -11,10 +11,11 @@ import json import os import sys -from collections.abc import Iterator +from collections.abc import Callable, Iterator from contextlib import contextmanager from dataclasses import asdict from pathlib import Path +from typing import Any import click import yaml @@ -90,6 +91,55 @@ def _emit_json(obj) -> None: click.echo(json.dumps(obj, indent=2, default=str, sort_keys=True)) +def _color_enabled() -> bool: + # Honour the de-facto conventions: NO_COLOR disables, FORCE_COLOR forces, + # otherwise colour only when stdout is an interactive terminal. Keeps pipes, + # CI, and `--json` output clean while still being pretty in a shell. + if os.environ.get("NO_COLOR"): + return False + if os.environ.get("FORCE_COLOR"): + return True + return sys.stdout.isatty() + + +def _style(text: str, **kwargs: Any) -> str: + return click.style(text, **kwargs) if _color_enabled() else text + + +def _echo(message: str = "", *, err: bool = False) -> None: + # click.echo strips ANSI when the stream isn't a TTY unless told otherwise; + # pass an explicit color flag so FORCE_COLOR into a pipe keeps the styling + # and NO_COLOR / plain pipes stay clean. + click.echo(message, err=err, color=_color_enabled()) + + +_SEVERITY_STYLE = { + "error": {"marker": "✗", "fg": "red"}, + "warning": {"marker": "!", "fg": "yellow"}, + "info": {"marker": "·", "fg": "cyan"}, +} + + +def _print_findings(findings: list) -> None: + for f in findings: + style = _SEVERITY_STYLE.get(f.severity, {"marker": "?", "fg": None}) + line = f"{style['marker']} [{f.code}] {f.message}" + _echo(_style(line, fg=style["fg"])) + + +def _progress_cb(verb: str) -> Callable[[str], None] | None: + # Progress is for humans watching a terminal; stay silent in pipes/CI/tests + # so machine output and captured test stdout aren't polluted. Writes to + # stderr so it never lands in piped stdout. + if not sys.stderr.isatty(): + return None + + def cb(step: str) -> None: + click.echo(_style(f" … {verb} {step}", fg="cyan"), err=True) + + return cb + + @click.group() @click.version_option(__version__, prog_name="vouch") def cli() -> None: @@ -150,39 +200,62 @@ def status(as_json: bool) -> None: if as_json: _emit_json(s) return - click.echo(f"KB at {s['kb_dir']}") - click.echo( - f" durable: {s['claims']} claims • {s['pages']} pages • " - f"{s['sources']} sources • {s['entities']} entities • " - f"{s['relations']} relations" + _echo(f"KB at {_style(str(s['kb_dir']), bold=True)}") + _echo( + f" durable: {_style(str(s['claims']), fg='cyan')} claims • " + f"{_style(str(s['pages']), fg='cyan')} pages • " + f"{_style(str(s['sources']), fg='cyan')} sources • " + f"{_style(str(s['entities']), fg='cyan')} entities • " + f"{_style(str(s['relations']), fg='cyan')} relations" + ) + pending = s["pending_proposals"] + pending_str = _style(str(pending), fg="yellow" if pending else "green") + _echo(f" pending: {pending_str} proposals") + present = s["index_present"] + index_str = ( + _style("present", fg="green") if present else _style("missing", fg="red") ) - click.echo(f" pending: {s['pending_proposals']} proposals") - click.echo(f" audit: {s['audit_events']} events • " - f"index: {'present' if s['index_present'] else 'missing'}") + _echo(f" audit: {_style(str(s['audit_events']), fg='cyan')} events • " + f"index: {index_str}") + + +def _findings_json(report) -> list[dict[str, Any]]: + return [ + {"severity": f.severity, "code": f.code, "message": f.message, + "object_ids": list(getattr(f, "object_ids", []) or [])} + for f in report.findings + ] @cli.command() @click.option("--stale-days", default=180, show_default=True, type=int) -def lint(stale_days: int) -> None: +@click.option("--json", "as_json", is_flag=True, help="Emit findings as JSON.") +def lint(stale_days: int, as_json: bool) -> None: """Surface user-actionable problems: broken citations, stale claims, dangling refs.""" store = _load_store() report = health.lint(store, stale_after_days=stale_days) - for f in report.findings: - marker = {"error": "✗", "warning": "!", "info": "·"}.get(f.severity, "?") - click.echo(f"{marker} [{f.code}] {f.message}") + if as_json: + _emit_json({"ok": report.ok, "findings": _findings_json(report)}) + sys.exit(0 if report.ok else 1) + _print_findings(report.findings) if not report.findings: - click.echo("clean") + _echo(_style("clean", fg="green")) sys.exit(0 if report.ok else 1) @cli.command() -def doctor() -> None: +@click.option("--json", "as_json", is_flag=True, help="Emit findings as JSON.") +def doctor(as_json: bool) -> None: """Full health sweep: lint + source verification + index check.""" store = _load_store() - report = health.doctor(store) - for f in report.findings: - marker = {"error": "✗", "warning": "!", "info": "·"}.get(f.severity, "?") - click.echo(f"{marker} [{f.code}] {f.message}") + report = health.doctor(store, on_progress=_progress_cb("verifying")) + if as_json: + _emit_json({ + "ok": report.ok, "counts": report.counts, + "findings": _findings_json(report), + }) + sys.exit(0 if report.ok else 1) + _print_findings(report.findings) click.echo(f"-- {report.counts}") sys.exit(0 if report.ok else 1) @@ -607,6 +680,7 @@ def crystallize(session_id: str, no_page: bool) -> None: @click.option("--rerank/--no-rerank", default=False) @click.option("--hyde/--no-hyde", default=False) @click.option("--explain/--no-explain", default=False) +@click.option("--json", "as_json", is_flag=True, help="Emit hits as JSON.") def search( query: str, limit: int, @@ -617,6 +691,7 @@ def search( rerank: bool, hyde: bool, explain: bool, + as_json: bool, ) -> None: """Search the KB.""" from . import index_db @@ -662,11 +737,25 @@ def search( click.echo("warning: rerank extras not installed; skipping rerank", err=True) + if as_json: + _emit_json({ + "backend": used, + "hits": [ + {"kind": k, "id": i, "snippet": snip, "score": score, + "backend": used} + for k, i, snip, score in hits + ], + }) + return + + label = _style(f"({used})", fg="green") for k, i, snip, score in hits: + loc = _style(f"{k}/{i}", fg="cyan") if explain: - click.echo(f"[{used}] {k}/{i}\tscore={score:.4f}\t{snip} ({used})") + sc = _style(f"score={score:.4f}", dim=True) + _echo(f"{label} {loc}\t{sc}\t{snip}") else: - click.echo(f"{k}/{i}\t{snip} ({used})") + _echo(f"{loc}\t{snip} {label}") @cli.command() @@ -690,7 +779,8 @@ def context(task: str, limit: int, max_chars: int | None, def index() -> None: """Rebuild state.db from durable files.""" store = _load_store() - stats = health.rebuild_index(store) + with _cli_errors(): + stats = health.rebuild_index(store, on_progress=_progress_cb("indexing")) click.echo(f"indexed: {stats}") @@ -820,7 +910,11 @@ def audit(tail: int, as_json: bool) -> None: def export(out_path: str) -> None: """Bundle the durable KB into a portable .tar.gz.""" store = _load_store() - manifest = bundle.export(store.kb_dir, dest=Path(out_path), actor=_whoami()) + with _cli_errors(): + manifest = bundle.export( + store.kb_dir, dest=Path(out_path), actor=_whoami(), + on_progress=_progress_cb("exporting"), + ) _emit_json({ "bundle_id": manifest["bundle_id"], "files": len(manifest["files"]), @@ -864,11 +958,12 @@ def import_apply_cmd(bundle_path: str, on_conflict: str) -> None: r = bundle.import_apply( store.kb_dir, Path(bundle_path), on_conflict=on_conflict, actor=_whoami(), + on_progress=_progress_cb("importing"), ) except RuntimeError as e: raise click.ClickException(str(e)) from e # Rebuild the index after a bulk import so search picks up new claims. - health.rebuild_index(store) + health.rebuild_index(store, on_progress=_progress_cb("indexing")) _emit_json(r) diff --git a/src/vouch/health.py b/src/vouch/health.py index ac3fe510..3f9a2d15 100644 --- a/src/vouch/health.py +++ b/src/vouch/health.py @@ -7,6 +7,7 @@ from __future__ import annotations +from collections.abc import Callable from dataclasses import dataclass, field from datetime import UTC, datetime, timedelta from pathlib import Path @@ -109,11 +110,19 @@ def lint(store: KBStore, *, stale_after_days: int = 180) -> HealthReport: return HealthReport(ok=ok, findings=findings, counts=status(store)) -def doctor(store: KBStore) -> HealthReport: - """Lint + source verification + index consistency. Slow but thorough.""" +def doctor( + store: KBStore, *, on_progress: Callable[[str], None] | None = None +) -> HealthReport: + """Lint + source verification + index consistency. Slow but thorough. + + `on_progress`, if given, is called with a short phase label as each stage + runs (source verification is the slow part). It never affects the result. + """ report = lint(store) # Source integrity (content hash). + if on_progress is not None: + on_progress("sources") for vr in verify_all(store): if not vr.stored_ok: report.findings.append(Finding( @@ -145,8 +154,19 @@ def doctor(store: KBStore) -> HealthReport: return report -def rebuild_index(store: KBStore) -> dict: - """Drop and rebuild state.db from the durable files. Idempotent.""" +def rebuild_index( + store: KBStore, *, on_progress: Callable[[str], None] | None = None +) -> dict: + """Drop and rebuild state.db from the durable files. Idempotent. + + `on_progress`, if given, is called with a short phase label ("claims", + "pages", "entities", "embeddings") as each stage starts — for CLI + progress display. It never affects the result. + """ + def _tick(phase: str) -> None: + if on_progress is not None: + on_progress(phase) + # Detect a stale embedding-model identity before reset() wipes the meta. try: from . import audit @@ -162,21 +182,25 @@ def rebuild_index(store: KBStore) -> dict: pass index_db.reset(store.kb_dir) with index_db.open_db(store.kb_dir) as conn: + _tick("claims") for c in store.list_claims(): index_db.index_claim( conn, id=c.id, text=c.text, type=c.type.value, status=c.status.value, tags=c.tags, ) + _tick("pages") for p in store.list_pages(): index_db.index_page( conn, id=p.id, title=p.title, body=p.body, type=p.type.value, tags=p.tags, ) + _tick("entities") for e in store.list_entities(): index_db.index_entity( conn, id=e.id, name=e.name, description=e.description, type=e.type.value, aliases=e.aliases, ) + _tick("embeddings") _rebuild_embeddings(store) return index_db.stats(store.kb_dir) diff --git a/tests/test_cli_output.py b/tests/test_cli_output.py new file mode 100644 index 00000000..c5150f1a --- /dev/null +++ b/tests/test_cli_output.py @@ -0,0 +1,105 @@ +"""Track 2 (#54) — friendlier CLI output: colour, --json, progress callbacks.""" + +from __future__ import annotations + +import json +from pathlib import Path + +import pytest +from click.testing import CliRunner + +from vouch import bundle, health +from vouch.cli import cli +from vouch.models import Claim +from vouch.storage import KBStore + + +@pytest.fixture +def store(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> KBStore: + s = KBStore.init(tmp_path) + monkeypatch.chdir(s.root) + src = s.put_source(b"jwt rotation notes") + s.put_claim(Claim(id="c1", text="JWT rotation matters", evidence=[src.id])) + health.rebuild_index(s) + return s + + +# --- 2a colour ------------------------------------------------------------ + + +def test_status_no_color_by_default_in_pipe( + store: KBStore, monkeypatch: pytest.MonkeyPatch +) -> None: + monkeypatch.delenv("FORCE_COLOR", raising=False) + monkeypatch.delenv("NO_COLOR", raising=False) + result = CliRunner().invoke(cli, ["status"]) + assert result.exit_code == 0, result.output + assert "\x1b[" not in result.output # no ANSI when not a TTY + + +def test_status_force_color_emits_ansi( + store: KBStore, monkeypatch: pytest.MonkeyPatch +) -> None: + monkeypatch.setenv("FORCE_COLOR", "1") + monkeypatch.delenv("NO_COLOR", raising=False) + result = CliRunner().invoke(cli, ["status"]) + assert result.exit_code == 0, result.output + assert "\x1b[" in result.output # ANSI present when forced + + +def test_no_color_wins_over_force_color( + store: KBStore, monkeypatch: pytest.MonkeyPatch +) -> None: + monkeypatch.setenv("FORCE_COLOR", "1") + monkeypatch.setenv("NO_COLOR", "1") + result = CliRunner().invoke(cli, ["status"]) + assert "\x1b[" not in result.output + + +# --- 2c --json ------------------------------------------------------------ + + +def test_lint_json(store: KBStore) -> None: + result = CliRunner().invoke(cli, ["lint", "--json"]) + payload = json.loads(result.output) + assert "ok" in payload and "findings" in payload + assert isinstance(payload["findings"], list) + + +def test_search_json(store: KBStore) -> None: + result = CliRunner().invoke(cli, ["search", "JWT", "--json"]) + assert result.exit_code == 0, result.output + payload = json.loads(result.output) + assert "backend" in payload + assert isinstance(payload["hits"], list) + + +def test_search_human_default_has_no_json(store: KBStore) -> None: + result = CliRunner().invoke(cli, ["search", "JWT"]) + assert result.exit_code == 0, result.output + with pytest.raises(json.JSONDecodeError): + json.loads(result.output) + + +# --- 2b progress callbacks ------------------------------------------------ + + +def test_rebuild_index_reports_progress(store: KBStore) -> None: + seen: list[str] = [] + health.rebuild_index(store, on_progress=seen.append) + assert {"claims", "pages", "entities", "embeddings"} <= set(seen) + + +def test_import_apply_reports_progress(store: KBStore, tmp_path: Path) -> None: + bundle_path = tmp_path / "kb.tar.gz" + bundle.export(store.kb_dir, dest=bundle_path) + dest = KBStore.init(tmp_path / "dest") + seen: list[str] = [] + bundle.import_apply(dest.kb_dir, bundle_path, on_progress=seen.append) + assert seen # at least one member written + reported + + +def test_export_reports_progress(store: KBStore, tmp_path: Path) -> None: + seen: list[str] = [] + bundle.export(store.kb_dir, dest=tmp_path / "k.tar.gz", on_progress=seen.append) + assert seen From 74f7a6ab75314afa853cac534be7ee32001ff642 Mon Sep 17 00:00:00 2001 From: Tet-9 Date: Thu, 21 May 2026 09:19:12 +0100 Subject: [PATCH 020/149] fix: narrow evidence validation to ArtifactNotFoundError in propose_claim Bare except Exception on store.get_source() silently swallowed real I/O and parse errors, masking them as a misleading ProposalError('unknown source/evidence id'). Narrowing both except clauses to ArtifactNotFoundError lets genuine errors propagate with their original type and message intact. Fixes #48 --- CHANGELOG.md | 1 + src/vouch/proposals.py | 4 ++-- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3a5b3d49..5898ea55 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -30,6 +30,7 @@ All notable changes to vouch are documented here. Format follows - Add `put_relation_idempotent()` to `KBStore` and use it in `supersede()` and `contradict()` so retrying after a partial failure converges to a consistent state instead of raising `ValueError`. - Raise `ProposalError("forbidden_self_approval")` in `proposals.approve()` when `approved_by == proposal.proposed_by`, enforcing the review-gate guarantee documented in the README and CONTRIBUTING. - `crystallize()` now sets `review.approver_role: trusted-agent` context so single-agent sessions can be crystallized without hitting the `forbidden_self_approval` guard (#47). +- Narrow `except Exception` to `except ArtifactNotFoundError` in `propose_claim()` evidence validation so I/O and parse errors propagate with their original type instead of being masked as `unknown source/evidence id` (#48). - Bundle import rejects tar members whose path escapes `kb_dir` (CVE-2007-4559, #9). Previously a crafted `.tar.gz` with a member named `../../evil.txt` could write outside `.vouch/`; the manifest diff --git a/src/vouch/proposals.py b/src/vouch/proposals.py index 47592e71..14e318c1 100644 --- a/src/vouch/proposals.py +++ b/src/vouch/proposals.py @@ -91,10 +91,10 @@ def propose_claim( for eid in evidence: try: store.get_source(eid) - except Exception: + except ArtifactNotFoundError: try: store.get_evidence(eid) - except Exception as e: + except ArtifactNotFoundError as e: raise ProposalError(f"unknown source/evidence id: {eid}") from e payload = { "id": slug_hint or _slugify(text), From 5d87d9b82fd42813a689314048318466eb6fe374 Mon Sep 17 00:00:00 2001 From: Tet-9 Date: Fri, 22 May 2026 09:56:28 +0100 Subject: [PATCH 021/149] fix: use pytest.raises to prevent false positive in corrupt source test Replace try/except pattern with pytest.raises(Exception) and assert the raised exception is not a misleading ProposalError('unknown source/evidence id'). Fixes Ruff B011 and ensures the test fails if propose_claim unexpectedly succeeds. Suggested by CodeRabbit review of #50. --- tests/test_cli.py | 12 +++--------- 1 file changed, 3 insertions(+), 9 deletions(-) diff --git a/tests/test_cli.py b/tests/test_cli.py index cdef295e..da419062 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -59,23 +59,17 @@ def test_reject_empty_reason_shows_clean_error(store: KBStore) -> None: def test_propose_claim_empty_text_shows_clean_error(store: KBStore) -> None: src = store.put_source(b"e") - result = CliRunner().invoke( - cli, ["propose-claim", "--text", " ", "--source", src.id] - ) + result = CliRunner().invoke(cli, ["propose-claim", "--text", " ", "--source", src.id]) _assert_clean_error(result, "claim text") def test_propose_claim_unknown_source_shows_clean_error(store: KBStore) -> None: - result = CliRunner().invoke( - cli, ["propose-claim", "--text", "ok", "--source", "deadbeef"] - ) + result = CliRunner().invoke(cli, ["propose-claim", "--text", "ok", "--source", "deadbeef"]) _assert_clean_error(result, "unknown source") def test_propose_entity_empty_name_shows_clean_error(store: KBStore) -> None: - result = CliRunner().invoke( - cli, ["propose-entity", "--name", " ", "--type", "project"] - ) + result = CliRunner().invoke(cli, ["propose-entity", "--name", " ", "--type", "project"]) _assert_clean_error(result, "entity name") From a0ee15c581c793dfab9f354890ce783451281b87 Mon Sep 17 00:00:00 2001 From: Tet-9 Date: Wed, 27 May 2026 23:40:51 +0100 Subject: [PATCH 022/149] test: restore corrupt source regression test after rebase --- tests/test_cli.py | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/tests/test_cli.py b/tests/test_cli.py index da419062..d385c7d2 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -374,3 +374,23 @@ def test_approve_batch_keep_going_best_effort( pending = {p.id for p in store.list_proposals(ProposalStatus.PENDING)} assert good[0] not in pending assert good[1] not in pending + + +def test_propose_claim_corrupt_source_surfaces_real_error(store: KBStore) -> None: + """Corrupt meta.yaml must surface a parse error, not 'unknown source/evidence id'.""" + import pytest + + from vouch.proposals import ProposalError, propose_claim + + src = store.put_source(b"evidence content") + meta = store.kb_dir / "sources" / src.id / "meta.yaml" + meta.write_text("{ invalid: yaml: [") + + with pytest.raises(Exception) as excinfo: + propose_claim( + store, text="some claim", evidence=[src.id], proposed_by="agent" + ) + assert not ( + isinstance(excinfo.value, ProposalError) + and "unknown source/evidence id" in str(excinfo.value) + ), f"real parse error was masked: {excinfo.value}" From 2827a7491a7816a1b1f2e325478237d0ef99df28 Mon Sep 17 00:00:00 2001 From: dripsmvcp <138900956+dripsmvcp@users.noreply.github.com> Date: Thu, 28 May 2026 14:44:00 +0900 Subject: [PATCH 023/149] feat(serve): add HTTP transport for vouch serve --transport http (#94) --- CHANGELOG.md | 10 ++ spec/transports.md | 81 ++++++++++++---- src/vouch/capabilities.py | 2 +- src/vouch/cli.py | 26 +++++- src/vouch/http_server.py | 145 +++++++++++++++++++++++++++++ src/vouch/jsonl_server.py | 10 +- tests/test_http_server.py | 188 ++++++++++++++++++++++++++++++++++++++ 7 files changed, 439 insertions(+), 23 deletions(-) create mode 100644 src/vouch/http_server.py create mode 100644 tests/test_http_server.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 6d4fff95..aef31f00 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,6 +15,16 @@ All notable changes to vouch are documented here. Format follows Trusted Publishing (OIDC). ### Added +- HTTP transport: `vouch serve --transport http` exposes the full `kb.*` + surface over HTTP, reusing the same dispatch table as the MCP/JSONL + transports (#94, implements VEP-0004). `POST /rpc` carries the JSONL + envelope; `GET /capabilities` and `GET /healthz` are unauthenticated. + Binds `127.0.0.1` by default and refuses any non-loopback bind without + both `--allow-public` and a bearer token (`--token` / `VOUCH_HTTP_TOKEN`, + constant-time compared). The `X-Vouch-Agent` header sets the audit actor + per request. Zero new runtime dependencies (stdlib `http.server`); no TLS + in-process — terminate at a reverse proxy. `kb.capabilities.transports` + now includes `http`. - Seed a cited starter source and claim during `vouch init`, print first-run next steps, and document a 30-second onboarding tour (#54). diff --git a/spec/transports.md b/spec/transports.md index 286cfdc4..ba48a2fd 100644 --- a/spec/transports.md +++ b/spec/transports.md @@ -117,27 +117,74 @@ before issuing other calls. --- -## 3. Choosing a transport +## 3. HTTP -| | MCP stdio | JSONL | -|---|---|---| -| Wired by an LLM host | yes (Claude Code, Cursor, Codex) | no — bring your own client | -| Resources / prompts | yes | no | -| Spec dependency | full MCP | none | -| Pipelining | per MCP semantics | yes | -| Test ergonomics | requires MCP host | trivial — `echo` + `jq` | +`vouch serve --transport http` exposes the same `kb.*` surface over a +long-lived HTTP server, so several clients can share one KB without each +spawning a subprocess. The method surface is identical — only framing +differs. -Use MCP when the consumer is an LLM agent inside a host. Use JSONL -when the consumer is a script, a CI job, or another server. +### Endpoints + +| Method & path | Auth | Body | +|----------------------|------|-----------------------------------------------------| +| `POST /rpc` | yes* | JSONL envelope (§2) as request body **and** response body | +| `GET /capabilities` | no | `kb.capabilities` JSON | +| `GET /healthz` | no | `{"ok": true}` liveness probe | + +\* `/rpc` requires a bearer token only when one is configured (see Auth). + +```bash +curl -s localhost:8731/rpc \ + -d '{"id":"r1","method":"kb.search","params":{"query":"jwt"}}' +# {"id":"r1","ok":true,"result":{"backend":"...","hits":[...]}} +``` + +### Bind policy + +Binds `127.0.0.1` by default. The server **refuses to bind a non-loopback +host** unless both `--allow-public` and a token are supplied — so an +unauthenticated KB can never be exposed to the network by accident. + +### Auth + +When a token is set (`--token` or `VOUCH_HTTP_TOKEN`), every `POST /rpc` +must send `Authorization: Bearer `; the comparison is +constant-time. `GET /capabilities` and `/healthz` are always +unauthenticated (they leak only the method list and liveness). There is +no in-process TLS — terminate TLS at a reverse proxy for public +deployments. The review gate is unchanged: HTTP clients file proposals +exactly like every other transport, and `kb.approve` over HTTP is the +same privileged operation it is everywhere. + +### Actor attribution + +The `X-Vouch-Agent` request header maps to the audit `actor` for that +request (mirroring `VOUCH_AGENT` for stdio/JSONL). Absent header → +`unknown-agent`. --- -## 4. Future transports (non-normative) +## 4. Choosing a transport + +| | MCP stdio | JSONL | HTTP | +|---|---|---|---| +| Wired by an LLM host | yes (Claude Code, Cursor, Codex) | no | no | +| Resources / prompts | yes | no | no | +| Multiple clients, one process | no | no | yes | +| Spec dependency | full MCP | none | none (plain HTTP) | +| Test ergonomics | requires MCP host | trivial — `echo` + `jq` | trivial — `curl` | + +Use MCP when the consumer is an LLM agent inside a host. Use JSONL when +the consumer is a local script or CI job. Use HTTP when several clients +(or a remote/self-hosted deployment) need to share one KB. + +--- -HTTP is on the roadmap (see [ROADMAP.md](../ROADMAP.md), 0.1). The -intended shape is one POST per call against `/rpc` with the JSONL -envelope as request body and response body. Localhost-bound by default; -auth story is `Authorization: Bearer ` from `config.yaml`. +## 5. Future transports (non-normative) -This isn't part of the current spec — it's a heads-up so implementers -don't accidentally choose conflicting conventions in the meantime. +An MCP **streamable-HTTP** transport (as opposed to the bespoke +JSONL-over-HTTP above) may follow if a hosted MCP client needs it; see +[VEP-0004](../proposals/VEP-0004-http-transport.md). Not part of the +current spec — a heads-up so implementers don't pick conflicting +conventions in the meantime. diff --git a/src/vouch/capabilities.py b/src/vouch/capabilities.py index 9abcc685..2fb5817f 100644 --- a/src/vouch/capabilities.py +++ b/src/vouch/capabilities.py @@ -74,5 +74,5 @@ def capabilities() -> Capabilities: methods=METHODS, retrieval=retrieval, review_gated=True, - transports=["mcp", "jsonl"], + transports=["mcp", "jsonl", "http"], ) diff --git a/src/vouch/cli.py b/src/vouch/cli.py index 100dcdce..30f7e49f 100644 --- a/src/vouch/cli.py +++ b/src/vouch/cli.py @@ -773,15 +773,33 @@ def import_apply_cmd(bundle_path: str, on_conflict: str) -> None: @cli.command() @click.option("--transport", default="stdio", show_default=True, - type=click.Choice(["stdio", "jsonl"])) -def serve(transport: str) -> None: - """Run the MCP server (stdio) or the JSONL tool server.""" + type=click.Choice(["stdio", "jsonl", "http"])) +@click.option("--host", default="127.0.0.1", show_default=True, + help="HTTP bind host (transport=http).") +@click.option("--port", default=None, type=int, + help="HTTP bind port (transport=http; default 8731).") +@click.option("--token", default=None, envvar="VOUCH_HTTP_TOKEN", + help="Bearer token for HTTP /rpc (or env VOUCH_HTTP_TOKEN). " + "Required to bind a non-loopback host.") +@click.option("--allow-public", is_flag=True, + help="Permit binding a non-loopback host (requires --token).") +def serve(transport: str, host: str, port: int | None, token: str | None, + allow_public: bool) -> None: + """Run the MCP server (stdio), the JSONL tool server, or the HTTP server.""" if transport == "stdio": from .server import run_stdio run_stdio() - else: + elif transport == "jsonl": from .jsonl_server import run_jsonl run_jsonl() + else: + from .http_server import DEFAULT_PORT, run_http + bind_port = port if port is not None else DEFAULT_PORT + try: + run_http(host, bind_port, token=token, allow_public=allow_public) + except RuntimeError as e: + # e.g. the non-loopback bind guard — show a clean Error: line. + raise click.ClickException(str(e)) from e if __name__ == "__main__": diff --git a/src/vouch/http_server.py b/src/vouch/http_server.py new file mode 100644 index 00000000..f71e1bc8 --- /dev/null +++ b/src/vouch/http_server.py @@ -0,0 +1,145 @@ +"""HTTP transport for `vouch serve` (VEP-0004). + +A long-lived HTTP front-end over the same `kb.*` dispatch table the MCP and +JSONL transports use — so multiple clients can share one KB without each +spawning a local subprocess. + +Endpoints: + POST /rpc JSONL envelope in, JSONL envelope out (see jsonl_server) + GET /capabilities kb.capabilities JSON (unauthenticated) + GET /healthz {"ok": true} liveness (unauthenticated) + +Safe by default: binds 127.0.0.1 and refuses any non-loopback bind unless +both --allow-public and a bearer token are given. The token gates /rpc only; +comparison is constant-time. There is no in-process TLS — terminate TLS at a +reverse proxy for public deployments. The review gate is unchanged: HTTP +clients file proposals exactly like every other transport. +""" + +from __future__ import annotations + +import hmac +import json +from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer +from typing import Any + +from . import jsonl_server +from .capabilities import capabilities as build_caps + +DEFAULT_PORT = 8731 +MAX_BODY_BYTES = 4 * 1024 * 1024 # reject oversized bodies before reading +_LOOPBACK_HOSTS = {"127.0.0.1", "::1", "localhost"} + + +class _VouchHTTPServer(ThreadingHTTPServer): + daemon_threads = True + token: str | None = None + + +class _Handler(BaseHTTPRequestHandler): + server_version = "vouch-http/0.1" + + # --- helpers ---------------------------------------------------------- + + def _send_json(self, code: int, obj: Any) -> None: + body = json.dumps(obj).encode() + self.send_response(code) + self.send_header("Content-Type", "application/json") + self.send_header("Content-Length", str(len(body))) + self.end_headers() + self.wfile.write(body) + + def _error(self, code: int, err_code: str, message: str) -> None: + self._send_json(code, {"ok": False, "error": {"code": err_code, "message": message}}) + + def _authorized(self) -> bool: + token = self.server.token # type: ignore[attr-defined] + if token is None: + return True + provided = self.headers.get("Authorization", "") + return hmac.compare_digest(provided, f"Bearer {token}") + + def log_message(self, *args: Any) -> None: + # Silence the default per-request stderr access log. + pass + + # --- routes ----------------------------------------------------------- + + def do_GET(self) -> None: + if self.path == "/healthz": + self._send_json(200, {"ok": True}) + elif self.path == "/capabilities": + self._send_json(200, build_caps().model_dump(mode="json")) + else: + self._error(404, "not_found", f"no such path: {self.path}") + + def do_POST(self) -> None: + if self.path != "/rpc": + self._error(404, "not_found", f"no such path: {self.path}") + return + if not self._authorized(): + self._error(401, "unauthorized", "missing or invalid bearer token") + return + try: + length = int(self.headers.get("Content-Length", 0)) + except ValueError: + self._error(400, "invalid_request", "bad Content-Length") + return + if length > MAX_BODY_BYTES: + self._error(413, "invalid_request", "request body too large") + return + raw = self.rfile.read(length) + try: + envelope = json.loads(raw or b"{}") + except json.JSONDecodeError as e: + self._error(400, "invalid_request", f"invalid JSON: {e}") + return + if not isinstance(envelope, dict): + self._error(400, "invalid_request", "envelope must be a JSON object") + return + + # Attribute the audit actor to the X-Vouch-Agent header for this + # request only (thread-local via ContextVar — see jsonl_server._actor). + agent = self.headers.get("X-Vouch-Agent") + reset = jsonl_server._actor.set(agent) if agent else None + try: + response = jsonl_server.handle_request(envelope) + finally: + if reset is not None: + jsonl_server._actor.reset(reset) + self._send_json(200, response) + + +def make_server( + host: str = "127.0.0.1", + port: int = DEFAULT_PORT, + *, + token: str | None = None, + allow_public: bool = False, +) -> _VouchHTTPServer: + """Build (but don't start) the HTTP server, enforcing the bind policy.""" + if host not in _LOOPBACK_HOSTS and not (allow_public and token): + raise RuntimeError( + f"refusing to bind non-loopback host {host!r} without both " + "--allow-public and a --token (set VOUCH_HTTP_TOKEN or pass --token)" + ) + server = _VouchHTTPServer((host, port), _Handler) + server.token = token + return server + + +def run_http( + host: str = "127.0.0.1", + port: int = DEFAULT_PORT, + *, + token: str | None = None, + allow_public: bool = False, +) -> None: + """Serve the kb.* surface over HTTP until interrupted.""" + server = make_server(host, port, token=token, allow_public=allow_public) + try: + server.serve_forever() + except KeyboardInterrupt: + pass + finally: + server.server_close() diff --git a/src/vouch/jsonl_server.py b/src/vouch/jsonl_server.py index b3536f60..075a2896 100644 --- a/src/vouch/jsonl_server.py +++ b/src/vouch/jsonl_server.py @@ -22,6 +22,7 @@ import sys import traceback from collections.abc import Callable +from contextvars import ContextVar from pathlib import Path from typing import Any @@ -48,6 +49,13 @@ discover_root, ) +# Per-request actor override. The HTTP transport sets this from the +# X-Vouch-Agent header so audit attribution is correct without mutating +# process-wide env (each ThreadingHTTPServer request thread gets its own +# context, so this is concurrency-safe). stdio/JSONL leave it unset and +# fall back to VOUCH_AGENT. +_actor: ContextVar[str | None] = ContextVar("vouch_actor", default=None) + def _store() -> KBStore: try: @@ -57,7 +65,7 @@ def _store() -> KBStore: def _agent() -> str: - return os.environ.get("VOUCH_AGENT", "unknown-agent") + return _actor.get() or os.environ.get("VOUCH_AGENT", "unknown-agent") # --- per-method handlers --------------------------------------------------- diff --git a/tests/test_http_server.py b/tests/test_http_server.py new file mode 100644 index 00000000..55c89319 --- /dev/null +++ b/tests/test_http_server.py @@ -0,0 +1,188 @@ +"""HTTP transport (VEP-0004 / #94): dispatch, auth, bind policy, attribution.""" + +from __future__ import annotations + +import json +import threading +import urllib.error +import urllib.request +from collections.abc import Iterator +from pathlib import Path + +import pytest + +from vouch.http_server import _VouchHTTPServer, make_server, run_http +from vouch.models import Claim, ProposalStatus +from vouch.storage import KBStore + + +@pytest.fixture +def kb(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> KBStore: + s = KBStore.init(tmp_path) + # Handlers resolve the KB from cwd via discover_root(). + monkeypatch.chdir(s.root) + src = s.put_source(b"jwt notes") + s.put_claim(Claim(id="c1", text="JWT rotation", evidence=[src.id])) + return s + + +def _serve(server: _VouchHTTPServer) -> Iterator[str]: + t = threading.Thread(target=server.serve_forever, daemon=True) + t.start() + host, port = server.server_address[0], server.server_address[1] + try: + yield f"http://{host}:{port}" + finally: + server.shutdown() + server.server_close() + + +@pytest.fixture +def base_url(kb: KBStore) -> Iterator[str]: + yield from _serve(make_server("127.0.0.1", 0)) + + +def _post(url: str, envelope: dict, headers: dict | None = None) -> tuple[int, dict]: + req = urllib.request.Request( + url + "/rpc", + data=json.dumps(envelope).encode(), + headers={"Content-Type": "application/json", **(headers or {})}, + method="POST", + ) + try: + with urllib.request.urlopen(req) as resp: + return resp.status, json.loads(resp.read()) + except urllib.error.HTTPError as e: + return e.code, json.loads(e.read()) + + +def _get(url: str, path: str) -> tuple[int, dict]: + try: + with urllib.request.urlopen(url + path) as resp: + return resp.status, json.loads(resp.read()) + except urllib.error.HTTPError as e: + return e.code, json.loads(e.read()) + + +# --- dispatch ------------------------------------------------------------- + + +def test_rpc_dispatches_to_kb_surface(base_url: str) -> None: + code, body = _post(base_url, {"id": "r1", "method": "kb.status"}) + assert code == 200 + assert body["ok"] is True + assert body["result"]["claims"] == 1 + + +def test_rpc_unknown_method(base_url: str) -> None: + _code, body = _post(base_url, {"id": "r2", "method": "kb.nope"}) + assert body["ok"] is False + assert body["error"]["code"] == "method_not_found" + + +def test_rpc_invalid_json(base_url: str) -> None: + req = urllib.request.Request( + base_url + "/rpc", data=b"{not json", method="POST", + headers={"Content-Type": "application/json"}, + ) + try: + urllib.request.urlopen(req) + raise AssertionError("expected HTTPError") + except urllib.error.HTTPError as e: + assert e.code == 400 + assert json.loads(e.read())["error"]["code"] == "invalid_request" + + +# --- read-only GET routes ------------------------------------------------- + + +def test_healthz(base_url: str) -> None: + code, body = _get(base_url, "/healthz") + assert code == 200 and body == {"ok": True} + + +def test_capabilities_advertises_http(base_url: str) -> None: + code, body = _get(base_url, "/capabilities") + assert code == 200 + assert "http" in body["transports"] + assert "kb.search" in body["methods"] + + +# --- auth ----------------------------------------------------------------- + + +def test_token_required_when_set(kb: KBStore) -> None: + gen = _serve(make_server("127.0.0.1", 0, token="s3cret")) + url = next(gen) + try: + code, body = _post(url, {"id": "r", "method": "kb.status"}) + assert code == 401 and body["error"]["code"] == "unauthorized" + + code, body = _post( + url, {"id": "r", "method": "kb.status"}, + headers={"Authorization": "Bearer s3cret"}, + ) + assert code == 200 and body["ok"] is True + + code, body = _post( + url, {"id": "r", "method": "kb.status"}, + headers={"Authorization": "Bearer wrong"}, + ) + assert code == 401 + finally: + with pytest.raises(StopIteration): + next(gen) + + +def test_get_routes_unauthenticated_even_with_token(kb: KBStore) -> None: + gen = _serve(make_server("127.0.0.1", 0, token="s3cret")) + url = next(gen) + try: + assert _get(url, "/healthz")[0] == 200 + assert _get(url, "/capabilities")[0] == 200 + finally: + with pytest.raises(StopIteration): + next(gen) + + +# --- bind policy ---------------------------------------------------------- + + +def test_non_loopback_requires_allow_public_and_token() -> None: + with pytest.raises(RuntimeError, match="non-loopback"): + make_server("0.0.0.0", 0) + with pytest.raises(RuntimeError, match="non-loopback"): + make_server("0.0.0.0", 0, allow_public=True) # token still missing + + +def test_non_loopback_allowed_with_token_and_flag() -> None: + server = make_server("0.0.0.0", 0, token="t", allow_public=True) + server.server_close() # constructed without raising = policy satisfied + + +# --- audit attribution via X-Vouch-Agent ---------------------------------- + + +def test_x_vouch_agent_sets_actor(kb: KBStore) -> None: + src = kb.list_sources()[0] + gen = _serve(make_server("127.0.0.1", 0)) + url = next(gen) + try: + code, body = _post( + url, + {"id": "r", "method": "kb.propose_claim", + "params": {"text": "claim via http", "evidence": [src.id]}}, + headers={"X-Vouch-Agent": "http-bot"}, + ) + assert code == 200 and body["ok"] is True, body + finally: + with pytest.raises(StopIteration): + next(gen) + pending = kb.list_proposals(ProposalStatus.PENDING) + assert any(p.proposed_by == "http-bot" for p in pending) + + +def test_run_http_rejects_public_bind_fast() -> None: + # run_http surfaces the same guard before binding anything. + with pytest.raises(RuntimeError, match="non-loopback"): + run_http("0.0.0.0", 0) From a0d05d0600ce041b6fa494002b02202ff2ab92be Mon Sep 17 00:00:00 2001 From: galuis116 <116897328+galuis116@users.noreply.github.com> Date: Thu, 28 May 2026 00:48:37 -0700 Subject: [PATCH 024/149] fix: reject dangling Relation/Page references at every write path Fixes #123 Co-Authored-By: Claude Opus 4.7 --- CHANGELOG.md | 1 + src/vouch/bundle.py | 161 +++++++++++++++++++++++++++++++++++++ src/vouch/proposals.py | 67 ++++++++++++++++ src/vouch/storage.py | 66 ++++++++++++++++ src/vouch/sync.py | 13 ++- tests/test_bundle.py | 175 +++++++++++++++++++++++++++++++++++++++++ tests/test_health.py | 21 ++++- tests/test_storage.py | 166 ++++++++++++++++++++++++++++++++++++++ tests/test_sync.py | 46 +++++++++++ 9 files changed, 711 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3a5b3d49..065459ad 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -27,6 +27,7 @@ All notable changes to vouch are documented here. Format follows or dry-running pending proposals without bypassing the review gate. ### Fixed +- `store.put_relation`, `store.put_relation_idempotent`, and `store.put_page` now reject artifacts whose foreign-id references don't resolve in the KB (relation `source` / `target` / `evidence`; page `entities` / `sources`). `proposals.propose_relation` and `proposals.propose_page` surface the same checks at proposal time as `ProposalError`. `bundle.import_check` and `sync.sync_check` run an equivalent cross-artifact pass against the post-merge id set so manifest-consistent bundles can't smuggle relations / pages whose references resolve to nothing — closes the write-time counterpart of the after-the-fact `dangling_relation` finding in `health.lint` (`src/vouch/health.py:135-145`). - Add `put_relation_idempotent()` to `KBStore` and use it in `supersede()` and `contradict()` so retrying after a partial failure converges to a consistent state instead of raising `ValueError`. - Raise `ProposalError("forbidden_self_approval")` in `proposals.approve()` when `approved_by == proposal.proposed_by`, enforcing the review-gate guarantee documented in the README and CONTRIBUTING. - `crystallize()` now sets `review.approver_role: trusted-agent` context so single-agent sessions can be crystallized without hitting the `forbidden_self_approval` guard (#47). diff --git a/src/vouch/bundle.py b/src/vouch/bundle.py index a0595464..378ed84f 100644 --- a/src/vouch/bundle.py +++ b/src/vouch/bundle.py @@ -231,6 +231,157 @@ def _validate_content(path: str, data: bytes, issues: list[str]) -> None: issues.append(f"schema validation failed: {path}: {e}") +def _artifact_id_from_path(path: str) -> tuple[str, str] | None: + """Return (kind, id) for a manifest path, or None for unrelated files. + + Mirrors `sync._artifact_kind` semantics but only emits the entries the + referential pass needs. `sources//...` collapses to ("source", + "") regardless of whether the path is meta.yaml or content. + """ + parts = path.split("/") + if len(parts) < 2: + return None + top = parts[0] + if top == "sources" and len(parts) >= 3: + return "source", parts[1] + singular = { + "claims": "claim", + "pages": "page", + "entities": "entity", + "relations": "relation", + "evidence": "evidence", + }.get(top) + if singular is None: + return None + return singular, Path(parts[-1]).stem + + +def _existing_ids(kb_dir: Path) -> dict[str, set[str]]: + """Snapshot the destination KB's artifact ids per kind. + + Used by the post-merge referential pass: an incoming relation / + page reference is satisfied if the target id is either already on + disk OR is being delivered by this same bundle. + """ + out: dict[str, set[str]] = { + "claim": set(), "page": set(), "entity": set(), + "source": set(), "evidence": set(), + } + for sub, kind, suffix in ( + ("claims", "claim", ".yaml"), + ("entities", "entity", ".yaml"), + ("relations", "relation", ".yaml"), + ("evidence", "evidence", ".yaml"), + ): + d = kb_dir / sub + if not d.is_dir(): + continue + for p in d.glob(f"*{suffix}"): + out[kind].add(p.stem) + pages_dir = kb_dir / "pages" + if pages_dir.is_dir(): + for p in pages_dir.glob("*.md"): + out["page"].add(p.stem) + sources_dir = kb_dir / "sources" + if sources_dir.is_dir(): + for sdir in sources_dir.iterdir(): + if (sdir / "meta.yaml").exists(): + out["source"].add(sdir.name) + return out + + +def _check_graph_integrity( + kb_dir: Path, + incoming: dict[str, bytes], + issues: list[str], +) -> None: + """Verify every Relation/Page/Claim in `incoming` resolves its refs. + + Closes the bundle / sync equivalent of `put_relation` + `put_page` + validation. References are satisfied against the post-merge id set + (destination KB ids plus incoming bundle ids), so a self-contained + bundle that ships an entity alongside the relation that points at + it still imports cleanly. Skips files whose bytes already failed + schema validation upstream so a malformed file does not produce a + second cryptic error. + """ + ids = _existing_ids(kb_dir) + incoming_meta: list[tuple[str, str, bytes]] = [] + for path, body in incoming.items(): + kind_id = _artifact_id_from_path(path) + if kind_id is None: + continue + kind, aid = kind_id + # Only artifacts that can satisfy a reference go into `ids`. + # `relation` is intentionally absent — relations aren't valid + # Relation endpoints. The incoming relation itself is still + # appended below so its refs get checked. + if kind in ids: + ids[kind].add(aid) + incoming_meta.append((path, kind, body)) + node_kinds = ("claim", "page", "entity", "source") + evidence_kinds = ("source", "evidence") + failed_schema = { + i.split(": ", 2)[1] for i in issues + if i.startswith("schema validation failed: ") + } + for path, kind, body in incoming_meta: + if path in failed_schema: + continue + try: + if kind == "relation": + rel = Relation.model_validate(yaml.safe_load(body)) + if not any(rel.source in ids[k] for k in node_kinds): + issues.append( + f"dangling reference: {path}: relation source " + f"{rel.source!r} not in bundle or destination" + ) + if not any(rel.target in ids[k] for k in node_kinds): + issues.append( + f"dangling reference: {path}: relation target " + f"{rel.target!r} not in bundle or destination" + ) + for eid in rel.evidence: + if not any(eid in ids[k] for k in evidence_kinds): + issues.append( + f"dangling reference: {path}: relation " + f"evidence {eid!r} not in bundle or destination" + ) + elif kind == "page": + page = _deserialize_page(body.decode()) + for cid in page.claims: + if cid not in ids["claim"]: + issues.append( + f"dangling reference: {path}: page claim " + f"{cid!r} not in bundle or destination" + ) + for eid in page.entities: + if eid not in ids["entity"]: + issues.append( + f"dangling reference: {path}: page entity " + f"{eid!r} not in bundle or destination" + ) + for sid in page.sources: + if sid not in ids["source"]: + issues.append( + f"dangling reference: {path}: page source " + f"{sid!r} not in bundle or destination" + ) + elif kind == "claim": + claim = Claim.model_validate(yaml.safe_load(body)) + for ref in claim.evidence: + if not any(ref in ids[k] for k in evidence_kinds): + issues.append( + f"dangling reference: {path}: claim citation " + f"{ref!r} not in bundle or destination" + ) + except Exception: + # Schema validation already ran on `body` in `_validate_content` + # and recorded any structural issue. Swallow here so a single + # malformed file doesn't mask the more useful upstream error. + continue + + def import_check(kb_dir: Path, bundle_path: Path) -> ImportCheckResult: """Diff a bundle against the destination KB without writing anything.""" new_files: list[str] = [] @@ -238,6 +389,7 @@ def import_check(kb_dir: Path, bundle_path: Path) -> ImportCheckResult: identical: list[str] = [] issues: list[str] = [] bundle_id = "" + incoming: dict[str, bytes] = {} with tarfile.open(bundle_path, "r:gz") as tar: try: @@ -278,6 +430,15 @@ def import_check(kb_dir: Path, bundle_path: Path) -> ImportCheckResult: issues.append(f"hash mismatch: {member.name}") continue _validate_content(member.name, body, issues) + incoming[member.name] = body + # Graph-integrity pass: the storage layer's put_relation / put_page + # validators run on direct writes, but import_apply writes member + # bytes straight to disk, so the only place to enforce referential + # integrity for a bundle is here. Refs are resolved against the + # post-merge id set (destination KB plus incoming bundle), so a + # self-contained bundle that ships an entity alongside the relation + # pointing at it still imports cleanly. + _check_graph_integrity(kb_dir, incoming, issues) return ImportCheckResult( ok=not issues, bundle_id=bundle_id, diff --git a/src/vouch/proposals.py b/src/vouch/proposals.py index 47592e71..77afded2 100644 --- a/src/vouch/proposals.py +++ b/src/vouch/proposals.py @@ -130,6 +130,25 @@ def propose_page( ) -> Proposal: if not title.strip(): raise ProposalError("page title is empty") + # Mirror the existence check `propose_claim` already runs on evidence + # ids: a page that lists a claim / entity / source id but never had it + # resolved is exactly the dangling-reference shape `store.put_page` + # used to silently accept (issue: graph-integrity write gates). + for cid in claim_ids or []: + try: + store.get_claim(cid) + except ArtifactNotFoundError as e: + raise ProposalError(f"unknown claim id: {cid}") from e + for eid in entity_ids or []: + try: + store.get_entity(eid) + except ArtifactNotFoundError as e: + raise ProposalError(f"unknown entity id: {eid}") from e + for sid in source_ids or []: + try: + store.get_source(sid) + except ArtifactNotFoundError as e: + raise ProposalError(f"unknown source id: {sid}") from e payload = { "id": slug_hint or _slugify(title), "title": title.strip(), @@ -191,6 +210,31 @@ def propose_relation( ) -> Proposal: if not src or not target or not relation: raise ProposalError("relation needs src, relation, target") + # Endpoint + evidence existence checks mirror the `propose_claim` + # citation loop. The corresponding write-time gate now lives in + # `store.put_relation` / `store.put_relation_idempotent`; surfacing + # the same error here means the agent sees a friendly `ProposalError` + # at proposal time instead of a downstream `ValueError` at approve. + if not _node_exists(store, src): + raise ProposalError( + f"unknown relation source endpoint: {src} (must be an existing " + f"claim, page, entity, or source id)" + ) + if not _node_exists(store, target): + raise ProposalError( + f"unknown relation target endpoint: {target} (must be an " + f"existing claim, page, entity, or source id)" + ) + for eid in evidence or []: + try: + store.get_source(eid) + except ArtifactNotFoundError: + try: + store.get_evidence(eid) + except ArtifactNotFoundError as e: + raise ProposalError( + f"unknown source/evidence id: {eid}" + ) from e rid = f"{src}--{relation}--{target}" payload = { "id": _slugify(rid), @@ -375,6 +419,29 @@ def _ensure_no_existing_artifact( ) +def _node_exists(store: KBStore, node_id: str) -> bool: + """True if `node_id` resolves to a Claim, Page, Entity, or Source. + + The set of valid Relation endpoint kinds; mirrors + `KBStore._node_exists` (storage.py) so propose-time and write-time + rejection use the same definition. + """ + if not node_id: + return False + for getter in ( + store.get_claim, + store.get_page, + store.get_entity, + store.get_source, + ): + try: + getter(node_id) + return True + except ArtifactNotFoundError: + continue + return False + + def _slugify(text: str) -> str: out = [] last_dash = False diff --git a/src/vouch/storage.py b/src/vouch/storage.py index d90c8c10..2e35e596 100644 --- a/src/vouch/storage.py +++ b/src/vouch/storage.py @@ -287,6 +287,40 @@ def list_sources(self) -> list[Source]: out.append(Source.model_validate(_yaml_load(meta.read_text()))) return out + # --- graph-integrity helpers ------------------------------------------ + + # Closes the structural counterpart of the #81 fix: every graph artifact + # (Relation source/target/evidence, Page entities/sources) must resolve + # to a known artifact in the KB before it lands on disk. The invariant + # was already articulated as a `dangling_relation` finding in + # `health.lint` (`src/vouch/health.py:135-145`) but no write path + # enforced it, so every approve / lifecycle / bundle / sync surface + # silently landed graph edges and pages pointing at nothing. + + def _node_exists(self, node_id: str) -> bool: + """True if `node_id` resolves to a Claim, Page, Entity, or Source.""" + if not node_id: + return False + if self._claim_path(node_id).exists(): + return True + if self._page_path(node_id).exists(): + return True + if self._entity_path(node_id).exists(): + return True + return (self._source_dir(node_id) / "meta.yaml").exists() + + def _evidence_ref_exists(self, ref_id: str) -> bool: + """True if `ref_id` resolves to a Source or Evidence record. + + Matches the citation surface that `put_claim` already accepts: + either a content-hash Source id or an Evidence id. + """ + if not ref_id: + return False + if (self._source_dir(ref_id) / "meta.yaml").exists(): + return True + return self._evidence_path(ref_id).exists() + # --- claims ------------------------------------------------------------ def put_claim(self, claim: Claim) -> Claim: @@ -343,6 +377,12 @@ def put_page(self, page: Page) -> Page: for cid in page.claims: if not self._claim_path(cid).exists(): raise ValueError(f"page {page.id} references unknown claim {cid}") + for eid in page.entities: + if not self._entity_path(eid).exists(): + raise ValueError(f"page {page.id} references unknown entity {eid}") + for sid in page.sources: + if not (self._source_dir(sid) / "meta.yaml").exists(): + raise ValueError(f"page {page.id} references unknown source {sid}") try: with self._page_path(page.id).open("x") as f: f.write(_serialize_page(page)) @@ -396,7 +436,27 @@ def list_entities(self) -> list[Entity]: # --- relations --------------------------------------------------------- + def _validate_relation_refs(self, rel: Relation) -> None: + if not self._node_exists(rel.source): + raise ValueError( + f"relation {rel.id} references unknown source endpoint " + f"{rel.source!r} (must be an existing claim, page, entity, " + f"or source id)" + ) + if not self._node_exists(rel.target): + raise ValueError( + f"relation {rel.id} references unknown target endpoint " + f"{rel.target!r} (must be an existing claim, page, entity, " + f"or source id)" + ) + for eid in rel.evidence: + if not self._evidence_ref_exists(eid): + raise ValueError( + f"relation {rel.id} cites unknown source/evidence {eid!r}" + ) + def put_relation(self, rel: Relation) -> Relation: + self._validate_relation_refs(rel) try: with self._relation_path(rel.id).open("x") as f: f.write(_yaml_dump(rel.model_dump(mode="json"))) @@ -424,6 +484,12 @@ def put_relation_idempotent(self, rel: Relation) -> Relation: text=f"{rel.source} {rel.relation.value} {rel.target}", ) return rel + # Validate before the exclusive create. Skipping validation for the + # "already on disk" branch above is deliberate — a relation that's + # already durable was validated when it landed; re-checking would + # turn supersede/contradict retries into spurious failures whenever + # the linked claim was subsequently archived or retracted. + self._validate_relation_refs(rel) try: with path.open("x") as f: f.write(_yaml_dump(rel.model_dump(mode="json"))) diff --git a/src/vouch/sync.py b/src/vouch/sync.py index a3ccc89a..c5016990 100644 --- a/src/vouch/sync.py +++ b/src/vouch/sync.py @@ -188,11 +188,14 @@ def _read_source_file(src: _SyncSource, path: str) -> bytes: return tar.extractfile(tar.getmember(path)).read() # type: ignore[union-attr] -def _validation_issues_for_source(src: _SyncSource) -> list[str]: +def _validation_issues_for_source( + src: _SyncSource, kb_dir: Path | None = None +) -> list[str]: issues: list[str] = [] if src.bundle_path is not None: check = bundle.export_check(src.bundle_path) issues.extend(check.issues) + incoming_bodies: dict[str, bytes] = {} for path, incoming in sorted(src.files.items()): reason = bundle._unsafe_name_reason(path) if reason is not None: @@ -207,13 +210,19 @@ def _validation_issues_for_source(src: _SyncSource) -> list[str]: issues.append(f"hash mismatch: {path}") continue bundle._validate_content(path, data, issues) + incoming_bodies[path] = data + # Graph-integrity pass mirrors the bundle import path so sync + # doesn't accept Relations / Pages whose endpoints / references + # neither exist locally nor arrive in the same sync. + if kb_dir is not None: + bundle._check_graph_integrity(kb_dir, incoming_bodies, issues) return issues def sync_check(kb_dir: Path, source_path: Path) -> SyncCheckResult: """Compare another KB or bundle with ``kb_dir`` without writing.""" src = _load_source(source_path) - issues = _validation_issues_for_source(src) + issues = _validation_issues_for_source(src, kb_dir=kb_dir) new_files: list[str] = [] identical: list[str] = [] conflicts: list[SyncConflict] = [] diff --git a/tests/test_bundle.py b/tests/test_bundle.py index a851204c..0af7d55f 100644 --- a/tests/test_bundle.py +++ b/tests/test_bundle.py @@ -355,3 +355,178 @@ def test_import_check_passes_when_member_matches_manifest( diff = bundle.import_check(store.kb_dir, bundle_path) assert not any("hash mismatch" in i for i in diff.issues), diff.issues + + +# --- graph integrity on the bundle path ---------------------------------- +# +# `import_apply` writes member bytes directly to disk and never goes +# through `put_relation` / `put_page`, so the storage-layer validators +# can't reach this surface. `_check_graph_integrity` is the bundle's +# equivalent gate: every Relation / Page reference must resolve against +# the post-merge id set (destination KB plus incoming bundle). + + +def _write_multi_member_bundle( + bundle_path: Path, members: dict[str, bytes] +) -> None: + """Build a manifest-consistent bundle with the given member bodies.""" + files = [ + { + "path": name, + "size": len(body), + "sha256": hashlib.sha256(body).hexdigest(), + } + for name, body in members.items() + ] + bundle_id = hashlib.sha256( + b"".join(sorted(f["sha256"].encode() for f in files)) + ).hexdigest() + manifest = { + "spec": bundle.SPEC_VERSION, + "bundle_id": bundle_id, + "files": files, + "counts": {}, + "safety": {"has_proposed": False, "has_state_db": False, + "has_audit_log": False}, + } + with tarfile.open(bundle_path, "w:gz") as tar: + for name, body in members.items(): + info = tarfile.TarInfo(name) + info.size = len(body) + tar.addfile(info, io.BytesIO(body)) + mf_bytes = json.dumps(manifest).encode() + mf_info = tarfile.TarInfo(bundle.MANIFEST_NAME) + mf_info.size = len(mf_bytes) + tar.addfile(mf_info, io.BytesIO(mf_bytes)) + + +def _relation_yaml(rid: str, source: str, target: str, evidence: list[str]) -> bytes: + import yaml as _yaml + return _yaml.safe_dump({ + "id": rid, "source": source, "relation": "uses", + "target": target, "confidence": 0.7, "evidence": evidence, + "created_at": "2026-05-27T00:00:00+00:00", + "updated_at": "2026-05-27T00:00:00+00:00", + }, sort_keys=False).encode() + + +def _page_md(pid: str, entities: list[str], sources: list[str]) -> bytes: + import yaml as _yaml + meta = { + "id": pid, "title": pid, "type": "concept", "status": "draft", + "claims": [], "entities": entities, "sources": sources, "tags": [], + "created_at": "2026-05-27T00:00:00+00:00", + "updated_at": "2026-05-27T00:00:00+00:00", + } + return f"---\n{_yaml.safe_dump(meta, sort_keys=False)}---\nbody".encode() + + +def test_import_check_rejects_relation_with_dangling_endpoints( + store: KBStore, tmp_path: Path +) -> None: + """A bundle that ships a relation whose source / target are absent + from both the bundle and the destination is rejected — the bundle + integrity story extends from per-file sha256 (#74) to referential + integrity of the graph it carries.""" + bundle_path = tmp_path / "evil-rel.tar.gz" + _write_multi_member_bundle(bundle_path, { + "relations/r-dangling.yaml": _relation_yaml( + "r-dangling", "ghost-source", "ghost-target", [], + ), + }) + + diff = bundle.import_check(store.kb_dir, bundle_path) + assert not diff.ok + assert any("dangling reference" in i and "source" in i for i in diff.issues) + assert any("dangling reference" in i and "target" in i for i in diff.issues) + + +def test_import_apply_refuses_dangling_relation_endpoints( + store: KBStore, tmp_path: Path +) -> None: + bundle_path = tmp_path / "evil-rel.tar.gz" + _write_multi_member_bundle(bundle_path, { + "relations/r-dangling.yaml": _relation_yaml( + "r-dangling", "ghost", "ghost2", [], + ), + }) + + with pytest.raises(RuntimeError, match="dangling reference"): + bundle.import_apply(store.kb_dir, bundle_path) + assert not (store.kb_dir / "relations" / "r-dangling.yaml").exists() + + +def test_import_check_rejects_page_with_dangling_refs( + store: KBStore, tmp_path: Path +) -> None: + bundle_path = tmp_path / "evil-page.tar.gz" + _write_multi_member_bundle(bundle_path, { + "pages/evil-page.md": _page_md( + "evil-page", + entities=["ghost-entity"], + sources=["ghost-source"], + ), + }) + + diff = bundle.import_check(store.kb_dir, bundle_path) + assert not diff.ok + assert any("dangling reference" in i and "page entity" in i for i in diff.issues) + assert any("dangling reference" in i and "page source" in i for i in diff.issues) + + with pytest.raises(RuntimeError, match="dangling reference"): + bundle.import_apply(store.kb_dir, bundle_path) + assert not (store.kb_dir / "pages" / "evil-page.md").exists() + + +def test_import_check_accepts_self_contained_bundle( + store: KBStore, tmp_path: Path +) -> None: + """A bundle that ships an entity alongside the relation that points + at it imports cleanly. The post-merge id set is the union of + destination + incoming, so a self-contained bundle does not get + penalised for not having its dependencies already on disk.""" + import yaml as _yaml + ent_yaml = _yaml.safe_dump({ + "id": "ent-x", "name": "X", "type": "project", + "aliases": [], "description": None, "page": None, + "created_at": "2026-05-27T00:00:00+00:00", + "updated_at": "2026-05-27T00:00:00+00:00", + }, sort_keys=False).encode() + ent2_yaml = _yaml.safe_dump({ + "id": "ent-y", "name": "Y", "type": "project", + "aliases": [], "description": None, "page": None, + "created_at": "2026-05-27T00:00:00+00:00", + "updated_at": "2026-05-27T00:00:00+00:00", + }, sort_keys=False).encode() + bundle_path = tmp_path / "self-contained.tar.gz" + _write_multi_member_bundle(bundle_path, { + "entities/ent-x.yaml": ent_yaml, + "entities/ent-y.yaml": ent2_yaml, + "relations/ent-x--uses--ent-y.yaml": _relation_yaml( + "ent-x--uses--ent-y", "ent-x", "ent-y", [], + ), + }) + + diff = bundle.import_check(store.kb_dir, bundle_path) + assert diff.ok, diff.issues + result = bundle.import_apply(store.kb_dir, bundle_path) + assert "relations/ent-x--uses--ent-y.yaml" in result["written"] + + +def test_import_check_resolves_refs_against_destination_kb( + store: KBStore, tmp_path: Path +) -> None: + """If the destination already has the entity, a bundle shipping just + the relation imports cleanly.""" + from vouch.models import Entity, EntityType + store.put_entity(Entity(id="local-a", name="A", type=EntityType.PROJECT)) + store.put_entity(Entity(id="local-b", name="B", type=EntityType.PROJECT)) + bundle_path = tmp_path / "rel-only.tar.gz" + _write_multi_member_bundle(bundle_path, { + "relations/local-a--uses--local-b.yaml": _relation_yaml( + "local-a--uses--local-b", "local-a", "local-b", [], + ), + }) + + diff = bundle.import_check(store.kb_dir, bundle_path) + assert diff.ok, diff.issues diff --git a/tests/test_health.py b/tests/test_health.py index ee4c356a..86e2cf2e 100644 --- a/tests/test_health.py +++ b/tests/test_health.py @@ -7,7 +7,7 @@ import pytest from vouch import health -from vouch.models import Claim, ClaimStatus, Relation +from vouch.models import Claim, ClaimStatus from vouch.storage import KBStore @@ -29,8 +29,23 @@ def test_lint_finds_broken_citation_when_source_removed(store: KBStore) -> None: def test_lint_dangling_relation(store: KBStore) -> None: src = store.put_source(b"e") store.put_claim(Claim(id="c1", text="t", evidence=[src.id])) - store.put_relation(Relation(id="rel-x", source="c1", - relation="uses", target="ghost")) + # Hand-write the YAML to simulate a relation that landed before + # `put_relation` enforced endpoint existence (or one introduced + # by a manual edit). `health.lint` is the after-the-fact safety + # net for exactly this case; the on-disk YAML is still a + # `dangling_relation` finding even though no current write path + # would land it. + dangling_yaml = ( + "id: rel-x\n" + "source: c1\n" + "relation: uses\n" + "target: ghost\n" + "confidence: 0.7\n" + "evidence: []\n" + "created_at: '2026-05-27T00:00:00+00:00'\n" + "updated_at: '2026-05-27T00:00:00+00:00'\n" + ) + (store.kb_dir / "relations" / "rel-x.yaml").write_text(dangling_yaml) report = health.lint(store) codes = {f.code for f in report.findings} assert "dangling_relation" in codes diff --git a/tests/test_storage.py b/tests/test_storage.py index 46eeb73f..cda3d152 100644 --- a/tests/test_storage.py +++ b/tests/test_storage.py @@ -187,6 +187,117 @@ def test_relation_round_trip(store: KBStore) -> None: assert [r.id for r in store.relations_to("b")] == ["a-uses-b"] +# --- graph integrity: relation / page write paths reject dangling refs --- +# +# The `dangling_relation` shape used to be reported by `health.lint` after +# the fact (`src/vouch/health.py:135-145`) but no writer enforced it, so +# every approve / lifecycle / bundle path silently landed broken edges. +# These regressions cover the structural counterpart of the #81 fix on +# the Relation + Page surface. + + +def test_put_relation_rejects_unknown_source_endpoint(store: KBStore) -> None: + store.put_entity(Entity(id="real-target", name="T", type=EntityType.PROJECT)) + rel = Relation(id="r1", source="ghost", relation=RelationType.USES, target="real-target") + with pytest.raises(ValueError, match="unknown source endpoint"): + store.put_relation(rel) + assert not (store.kb_dir / "relations" / "r1.yaml").exists() + + +def test_put_relation_rejects_unknown_target_endpoint(store: KBStore) -> None: + store.put_entity(Entity(id="real-src", name="S", type=EntityType.PROJECT)) + rel = Relation(id="r2", source="real-src", relation=RelationType.USES, target="ghost") + with pytest.raises(ValueError, match="unknown target endpoint"): + store.put_relation(rel) + assert not (store.kb_dir / "relations" / "r2.yaml").exists() + + +def test_put_relation_rejects_unknown_evidence_ref(store: KBStore) -> None: + store.put_entity(Entity(id="x", name="X", type=EntityType.PROJECT)) + store.put_entity(Entity(id="y", name="Y", type=EntityType.PROJECT)) + rel = Relation( + id="r3", source="x", relation=RelationType.USES, target="y", + evidence=["ghost-source-or-evidence"], + ) + with pytest.raises(ValueError, match="unknown source/evidence"): + store.put_relation(rel) + assert not (store.kb_dir / "relations" / "r3.yaml").exists() + + +def test_put_relation_accepts_source_id_as_endpoint(store: KBStore) -> None: + # The Relation endpoint surface is documented in the README §"Object + # model" as 'entities / claims / pages'; in practice Source ids are + # also valid graph nodes (sources back claims and crystallize into + # pages). Keep that surface explicit here. + src = store.put_source(b"source-as-node", title="t") + rel = Relation( + id=f"r-{src.id[:8]}", + source=src.id, relation=RelationType.REFERENCES, target=src.id, + evidence=[src.id], + ) + store.put_relation(rel) + assert store.get_relation(rel.id).source == src.id + + +def test_put_relation_idempotent_validates_endpoints_on_first_write( + store: KBStore, +) -> None: + # Lifecycle ops (supersede / contradict) reach `put_relation_idempotent` + # with both endpoints loaded via `get_claim`, so they always satisfy + # the new gate. The negative case here proves a hand-built call with + # a dangling endpoint is still rejected. + store.put_entity(Entity(id="ok", name="OK", type=EntityType.PROJECT)) + rel = Relation(id="r-id", source="ok", relation=RelationType.USES, target="ghost") + with pytest.raises(ValueError, match="unknown target endpoint"): + store.put_relation_idempotent(rel) + assert not (store.kb_dir / "relations" / "r-id.yaml").exists() + + +def test_put_relation_idempotent_skips_revalidation_on_existing_file( + store: KBStore, tmp_path: Path, +) -> None: + # If a relation already exists on disk, `put_relation_idempotent` + # converges without re-checking endpoints. This protects supersede / + # contradict retries from spurious failures if the linked claim was + # later archived or retracted. + store.put_entity(Entity(id="p", name="P", type=EntityType.PROJECT)) + store.put_entity(Entity(id="q", name="Q", type=EntityType.PROJECT)) + rel = Relation(id="r-pq", source="p", relation=RelationType.USES, target="q") + store.put_relation(rel) + # Now "remove" target from the KB. + (store.kb_dir / "entities" / "q.yaml").unlink() + # Repeat call must not raise even though target no longer exists. + store.put_relation_idempotent(rel) + + +def test_put_page_rejects_unknown_entity_ref(store: KBStore) -> None: + page = Page(id="p-bad-ent", title="t", body="b", entities=["ghost-entity"]) + with pytest.raises(ValueError, match="unknown entity"): + store.put_page(page) + assert not (store.kb_dir / "pages" / "p-bad-ent.md").exists() + + +def test_put_page_rejects_unknown_source_ref(store: KBStore) -> None: + page = Page(id="p-bad-src", title="t", body="b", sources=["ghost-src"]) + with pytest.raises(ValueError, match="unknown source"): + store.put_page(page) + assert not (store.kb_dir / "pages" / "p-bad-src.md").exists() + + +def test_put_page_accepts_resolvable_entity_and_source_refs( + store: KBStore, +) -> None: + src = store.put_source(b"hello", title="hi") + store.put_entity(Entity(id="ent1", name="E", type=EntityType.CONCEPT)) + page = Page( + id="p-ok", title="t", body="b", + entities=["ent1"], sources=[src.id], + ) + store.put_page(page) + assert store.get_page("p-ok").entities == ["ent1"] + assert store.get_page("p-ok").sources == [src.id] + + # --- evidence ------------------------------------------------------------- @@ -318,6 +429,37 @@ def test_propose_relation_then_approve(store: KBStore) -> None: assert r.relation == RelationType.CONTRADICTS +def test_propose_relation_rejects_unknown_source(store: KBStore) -> None: + src = store.put_source(b"e") + store.put_claim(Claim(id="c-real", text="t", evidence=[src.id])) + with pytest.raises(ProposalError, match="unknown relation source endpoint"): + propose_relation( + store, src="ghost", relation="uses", target="c-real", + proposed_by="a", + ) + + +def test_propose_relation_rejects_unknown_target(store: KBStore) -> None: + src = store.put_source(b"e") + store.put_claim(Claim(id="c-real", text="t", evidence=[src.id])) + with pytest.raises(ProposalError, match="unknown relation target endpoint"): + propose_relation( + store, src="c-real", relation="uses", target="ghost", + proposed_by="a", + ) + + +def test_propose_relation_rejects_unknown_evidence_ref(store: KBStore) -> None: + src = store.put_source(b"e") + store.put_claim(Claim(id="c1", text="t", evidence=[src.id])) + store.put_claim(Claim(id="c2", text="t2", evidence=[src.id])) + with pytest.raises(ProposalError, match="unknown source/evidence"): + propose_relation( + store, src="c1", relation="contradicts", target="c2", + evidence=["nonexistent"], proposed_by="a", + ) + + def test_propose_page_round_trip_through_approval(store: KBStore) -> None: src = store.put_source(b"e") claim = store.put_claim(Claim(id="c1", text="t", evidence=[src.id])) @@ -330,6 +472,30 @@ def test_propose_page_round_trip_through_approval(store: KBStore) -> None: assert store.get_page(artifact.id).body == "body" +def test_propose_page_rejects_unknown_claim_ref(store: KBStore) -> None: + with pytest.raises(ProposalError, match="unknown claim id"): + propose_page( + store, title="t", body="b", + claim_ids=["ghost-claim"], proposed_by="a", + ) + + +def test_propose_page_rejects_unknown_entity_ref(store: KBStore) -> None: + with pytest.raises(ProposalError, match="unknown entity id"): + propose_page( + store, title="t", body="b", + entity_ids=["ghost-entity"], proposed_by="a", + ) + + +def test_propose_page_rejects_unknown_source_ref(store: KBStore) -> None: + with pytest.raises(ProposalError, match="unknown source id"): + propose_page( + store, title="t", body="b", + source_ids=["ghost-source"], proposed_by="a", + ) + + # --- lifecycle ------------------------------------------------------------ diff --git a/tests/test_sync.py b/tests/test_sync.py index 28245cf1..6ed60d16 100644 --- a/tests/test_sync.py +++ b/tests/test_sync.py @@ -129,3 +129,49 @@ def test_sync_check_cli_outputs_report( payload = json.loads(result.output) assert payload["source_type"] == "kb" assert "claims/c1.yaml" in payload["new_files"] + + +# --- graph integrity on the sync path ------------------------------------ +# +# Sync walks the same `bundle._validate_content` per-file pass as +# import, then also calls `bundle._check_graph_integrity` against the +# destination KB. So a directory-or-bundle source whose Relation / +# Page references resolve to nothing locally or in the source itself +# must be rejected by both sync_check and sync_apply. + + +def _hand_write_dangling_relation_kb(root: Path) -> Path: + """Build a `.vouch/` directory whose only relation has dangling + endpoints. Bypasses `put_relation` (which would now reject it) by + writing the YAML directly, simulating an attacker-supplied source.""" + kb = _store(root) + rel_yaml = ( + "id: r-dangling\n" + "source: ghost-source\n" + "relation: uses\n" + "target: ghost-target\n" + "confidence: 0.7\n" + "evidence: []\n" + "created_at: '2026-05-27T00:00:00+00:00'\n" + "updated_at: '2026-05-27T00:00:00+00:00'\n" + ) + (kb.kb_dir / "relations" / "r-dangling.yaml").write_text(rel_yaml) + return kb.root + + +def test_sync_check_rejects_dangling_relation_from_directory_source( + tmp_path: Path, +) -> None: + src_root = _hand_write_dangling_relation_kb(tmp_path / "incoming") + dest = _store(tmp_path / "dest") + report = sync.sync_check(dest.kb_dir, src_root) + assert not report.ok + assert any("dangling reference" in i for i in report.issues), report.issues + + +def test_sync_apply_refuses_dangling_relation(tmp_path: Path) -> None: + src_root = _hand_write_dangling_relation_kb(tmp_path / "incoming") + dest = _store(tmp_path / "dest") + with pytest.raises(RuntimeError, match="dangling reference"): + sync.sync_apply(dest.kb_dir, src_root) + assert not (dest.kb_dir / "relations" / "r-dangling.yaml").exists() From d6964e8e0686c1a9ec100b61d54e6680b5ba548c Mon Sep 17 00:00:00 2001 From: galuis116 <116897328+galuis116@users.noreply.github.com> Date: Thu, 28 May 2026 01:38:18 -0700 Subject: [PATCH 025/149] fix: enforce Source content-addressing on bundle/sync import Fixes #125 Co-Authored-By: Claude Opus 4.7 --- CHANGELOG.md | 1 + src/vouch/bundle.py | 50 ++++++++++++++++ src/vouch/sync.py | 1 + tests/test_bundle.py | 135 +++++++++++++++++++++++++++++++++++++++++++ tests/test_sync.py | 30 ++++++++++ 5 files changed, 217 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 040de89f..e1c6f7b1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -27,6 +27,7 @@ All notable changes to vouch are documented here. Format follows or dry-running pending proposals without bypassing the review gate. ### Fixed +- `bundle.import_check` / `import_apply` and `sync.sync_check` / `sync_apply` now enforce the Source content-addressing invariant: a `sources//content` member must hash to ``, and a `sources//meta.yaml` must carry a matching `id`/`hash`. Previously the import side trusted the bundle's directory layout, so a manifest-consistent bundle could land a Source whose content did not match its claimed id — `verify_source` would report `stored_ok=False` only after the import had already succeeded with a clean `bundle.import` audit event. The per-file sha256 gate (#74) only proves bytes match the manifest; this closes the write-time counterpart of the `verify.verify_source` detection. - Add `put_relation_idempotent()` to `KBStore` and use it in `supersede()` and `contradict()` so retrying after a partial failure converges to a consistent state instead of raising `ValueError`. - Raise `ProposalError("forbidden_self_approval")` in `proposals.approve()` when `approved_by == proposal.proposed_by`, enforcing the review-gate guarantee documented in the README and CONTRIBUTING. - `crystallize()` now sets `review.approver_role: trusted-agent` context so single-agent sessions can be crystallized without hitting the `forbidden_self_approval` guard (#47). diff --git a/src/vouch/bundle.py b/src/vouch/bundle.py index fa3be0a4..e856c171 100644 --- a/src/vouch/bundle.py +++ b/src/vouch/bundle.py @@ -242,6 +242,55 @@ def _validate_content(path: str, data: bytes, issues: list[str]) -> None: issues.append(f"schema validation failed: {path}: {e}") +def _check_source_content_address(path: str, body: bytes, issues: list[str]) -> None: + """Enforce the Source content-addressing invariant on import. + + A Source's id is the sha256 of its content (README: "content-addressed + by sha256"; `storage.put_source` derives the id from the bytes, and + `verify.verify_source` re-checks `sha256(content) == id`). But + `import_apply` writes `sources//{meta.yaml,content}` straight from + the tarball, so without this check a hand-built bundle can land a + source whose content does not hash to its claimed id. The per-file + sha256 gate (#74) only proves the bytes match the manifest, not that + they match the content-address — so a manifest-consistent bundle could + substitute the evidence behind a legitimate-looking source id. A claim + that "cites source X" would then point at bytes that were never hashed + to X; `verify_source` would report `stored_ok=False` only after the + import already succeeded with a clean `bundle.import` audit event. + """ + parts = path.split("/") + if len(parts) < 3 or parts[0] != "sources": + return + claimed_id = parts[1] + leaf = parts[-1] + if leaf == "content": + actual = sha256_hex(body) + if actual != claimed_id: + issues.append( + f"source content-address mismatch: {path}: content hashes " + f"to {actual} but is stored under id {claimed_id}" + ) + elif leaf == "meta.yaml": + try: + meta = yaml.safe_load(body) + except Exception: + return # a parse failure is already surfaced by _validate_content + if not isinstance(meta, dict): + return + mid = meta.get("id") + if mid is not None and mid != claimed_id: + issues.append( + f"source id mismatch: {path}: meta.yaml id {mid!r} does not " + f"match its content-address directory {claimed_id!r}" + ) + mhash = meta.get("hash") + if mhash is not None and mhash != claimed_id: + issues.append( + f"source hash mismatch: {path}: meta.yaml hash {mhash!r} does " + f"not match its content-address directory {claimed_id!r}" + ) + + def import_check(kb_dir: Path, bundle_path: Path) -> ImportCheckResult: """Diff a bundle against the destination KB without writing anything.""" new_files: list[str] = [] @@ -287,6 +336,7 @@ def import_check(kb_dir: Path, bundle_path: Path) -> ImportCheckResult: issues.append(f"hash mismatch: {member.name}") continue _validate_content(member.name, body, issues) + _check_source_content_address(member.name, body, issues) for path in manifest_paths: try: tar.getmember(path) diff --git a/src/vouch/sync.py b/src/vouch/sync.py index a3ccc89a..8af93aea 100644 --- a/src/vouch/sync.py +++ b/src/vouch/sync.py @@ -207,6 +207,7 @@ def _validation_issues_for_source(src: _SyncSource) -> list[str]: issues.append(f"hash mismatch: {path}") continue bundle._validate_content(path, data, issues) + bundle._check_source_content_address(path, data, issues) return issues diff --git a/tests/test_bundle.py b/tests/test_bundle.py index dbbbdefe..a686f5f5 100644 --- a/tests/test_bundle.py +++ b/tests/test_bundle.py @@ -408,3 +408,138 @@ def test_import_check_passes_when_member_matches_manifest(store: KBStore, tmp_pa diff = bundle.import_check(store.kb_dir, bundle_path) assert not any("hash mismatch" in i for i in diff.issues), diff.issues + + +# --- source content-addressing on the bundle path ------------------------ +# +# A Source's id is the sha256 of its content (README: "content-addressed +# by sha256"). import_apply writes sources//{meta.yaml,content} +# straight from the tarball, so a manifest-consistent bundle could ship a +# source whose content does NOT hash to its claimed id. The per-file sha256 +# gate (#74) only proves bytes match the manifest, not the content-address. + + +def _write_source_bundle( + bundle_path: Path, *, dir_id: str, content: bytes, + meta_id: str | None = None, meta_hash: str | None = "__dir__", +) -> None: + """Build a one-source bundle with full control over the (lying) ids. + + `meta_id` / `meta_hash` default to the directory id so an honest + bundle is the default; pass explicit values to model an attack. + The manifest always records each member's real sha256, so the #74 + per-file integrity check passes and the content-address check is the + only thing that can catch the lie. + """ + import hashlib as _hashlib + + import yaml as _yaml + + if meta_id is None: + meta_id = dir_id + if meta_hash == "__dir__": + meta_hash = dir_id + meta = { + "id": meta_id, "type": "file", "locator": "x.txt", "title": "t", + "hash": meta_hash, "immutable": True, "scope": "project", + "byte_size": len(content), "media_type": "text/plain", + "created_at": "2026-05-27T00:00:00+00:00", "metadata": {}, "tags": [], + } + meta_bytes = _yaml.safe_dump(meta, sort_keys=False).encode() + members = { + f"sources/{dir_id}/meta.yaml": meta_bytes, + f"sources/{dir_id}/content": content, + } + files = [ + {"path": p, "size": len(b), "sha256": _hashlib.sha256(b).hexdigest()} + for p, b in members.items() + ] + manifest = { + "spec": bundle.SPEC_VERSION, "bundle_id": "deadbeef", + "files": files, "counts": {}, + "safety": {"has_proposed": False, "has_state_db": False, + "has_audit_log": False}, + } + with tarfile.open(bundle_path, "w:gz") as tar: + for name, body in members.items(): + info = tarfile.TarInfo(name) + info.size = len(body) + tar.addfile(info, io.BytesIO(body)) + mf_bytes = json.dumps(manifest).encode() + mf_info = tarfile.TarInfo(bundle.MANIFEST_NAME) + mf_info.size = len(mf_bytes) + tar.addfile(mf_info, io.BytesIO(mf_bytes)) + + +def test_import_rejects_source_content_address_mismatch( + store: KBStore, tmp_path: Path +) -> None: + """A source whose content does not hash to its claimed id is rejected. + Without this the KB lands internally inconsistent (verify_source would + report stored_ok=False) while the import logs a clean bundle.import.""" + dir_id = "a" * 64 # well-formed hex, but not the hash of `content` + bundle_path = tmp_path / "lying-source.tar.gz" + _write_source_bundle(bundle_path, dir_id=dir_id, content=b"not-the-hashed-bytes") + + diff = bundle.import_check(store.kb_dir, bundle_path) + assert not diff.ok + assert any("content-address mismatch" in i for i in diff.issues), diff.issues + + with pytest.raises(RuntimeError, match="content-address mismatch"): + bundle.import_apply(store.kb_dir, bundle_path) + assert not (store.kb_dir / "sources" / dir_id / "content").exists() + + +def test_import_rejects_source_meta_id_mismatch( + store: KBStore, tmp_path: Path +) -> None: + """meta.yaml's id must equal its content-address directory.""" + content = b"real content" + dir_id = hashlib.sha256(content).hexdigest() + bundle_path = tmp_path / "meta-id-lie.tar.gz" + # Content honestly hashes to dir_id, but meta claims a different id. + _write_source_bundle( + bundle_path, dir_id=dir_id, content=content, + meta_id="b" * 64, meta_hash="b" * 64, + ) + + diff = bundle.import_check(store.kb_dir, bundle_path) + assert not diff.ok + assert any("source id mismatch" in i for i in diff.issues), diff.issues + + with pytest.raises(RuntimeError, match="source id mismatch"): + bundle.import_apply(store.kb_dir, bundle_path) + + +def test_import_accepts_honest_content_addressed_source( + store: KBStore, tmp_path: Path +) -> None: + """A source whose content hashes to its id imports cleanly — the new + check must not reject legitimate bundles produced by `vouch export`.""" + content = b"genuine source bytes" + dir_id = hashlib.sha256(content).hexdigest() + bundle_path = tmp_path / "honest.tar.gz" + _write_source_bundle(bundle_path, dir_id=dir_id, content=content) + + diff = bundle.import_check(store.kb_dir, bundle_path) + assert not any("content-address" in i or "source id" in i for i in diff.issues), diff.issues + result = bundle.import_apply(store.kb_dir, bundle_path) + assert f"sources/{dir_id}/content" in result["written"] + assert store.read_source_content(dir_id) == content + + +def test_export_then_import_roundtrip_passes_content_address_check( + store: KBStore, tmp_path: Path +) -> None: + """End-to-end: a real bundle from `vouch export` imports without + tripping the content-address check (guards against false positives).""" + src = store.put_source(b"round-trip bytes", title="doc") + store.put_claim(Claim(id="c1", text="alpha", evidence=[src.id])) + bundle_path = tmp_path / "out.tar.gz" + bundle.export(store.kb_dir, dest=bundle_path) + + dest = KBStore.init(tmp_path / "dest") + diff = bundle.import_check(dest.kb_dir, bundle_path) + assert diff.ok, diff.issues + bundle.import_apply(dest.kb_dir, bundle_path) + assert dest.read_source_content(src.id) == b"round-trip bytes" diff --git a/tests/test_sync.py b/tests/test_sync.py index 28245cf1..aaf313aa 100644 --- a/tests/test_sync.py +++ b/tests/test_sync.py @@ -129,3 +129,33 @@ def test_sync_check_cli_outputs_report( payload = json.loads(result.output) assert payload["source_type"] == "kb" assert "claims/c1.yaml" in payload["new_files"] + + +def test_sync_rejects_source_content_address_mismatch(tmp_path: Path) -> None: + """Sync walks the same per-file validation as bundle import, so a + source directory whose content does not hash to its name must be + rejected by sync_check and refused by sync_apply. The dangling source + is hand-written (bypassing put_source, which would recompute the id) + to model an attacker-supplied or corrupted sync source.""" + import yaml + + incoming = _store(tmp_path / "incoming") + dir_id = "a" * 64 # not the hash of the content below + sdir = incoming.kb_dir / "sources" / dir_id + sdir.mkdir(parents=True) + (sdir / "content").write_bytes(b"attacker-controlled bytes") + (sdir / "meta.yaml").write_text(yaml.safe_dump({ + "id": dir_id, "type": "file", "locator": "x.txt", "title": "t", + "hash": dir_id, "immutable": True, "scope": "project", + "byte_size": 25, "media_type": "text/plain", + "created_at": "2026-05-27T00:00:00+00:00", "metadata": {}, "tags": [], + }, sort_keys=False)) + + dest = _store(tmp_path / "dest") + report = sync.sync_check(dest.kb_dir, incoming.root) + assert not report.ok + assert any("content-address mismatch" in i for i in report.issues), report.issues + + with pytest.raises(RuntimeError, match="content-address mismatch"): + sync.sync_apply(dest.kb_dir, incoming.root) + assert not (dest.kb_dir / "sources" / dir_id / "content").exists() From 5485ff8c77d109ddede4f461e548e6bcfdf6115e Mon Sep 17 00:00:00 2001 From: plind-junior <59729252+plind-junior@users.noreply.github.com> Date: Thu, 28 May 2026 21:56:41 +0900 Subject: [PATCH 026/149] docs: add PROJECT-INDEX.md codebase briefing --- docs/PROJECT-INDEX.md | 550 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 550 insertions(+) create mode 100644 docs/PROJECT-INDEX.md diff --git a/docs/PROJECT-INDEX.md b/docs/PROJECT-INDEX.md new file mode 100644 index 00000000..c0511fc2 --- /dev/null +++ b/docs/PROJECT-INDEX.md @@ -0,0 +1,550 @@ +# vouch — Project Index + +> Git-native, review-gated knowledge base for LLM agents. +> MCP server + JSONL tool server + CLI. + +**Package:** `vouch-kb` (PyPI) | **CLI command:** `vouch` | **Version:** 0.1.0 (alpha) +**Language:** Python 3.11+ | **Build:** Hatchling | **License:** MIT + +--- + +## Table of Contents + +1. [Architecture Overview](#architecture-overview) +2. [Module Map](#module-map) +3. [Object Model](#object-model) +4. [Data Flow](#data-flow) +5. [Storage Layout](#storage-layout) +6. [Transport Surfaces](#transport-surfaces) +7. [CLI Reference](#cli-reference) +8. [MCP / JSONL Tool Surface](#mcp--jsonl-tool-surface) +9. [Embeddings Stack](#embeddings-stack) +10. [Test Structure](#test-structure) +11. [CI/CD](#cicd) +12. [Supporting Directories](#supporting-directories) +13. [Dependencies](#dependencies) +14. [Codebase Statistics](#codebase-statistics) + +--- + +## Architecture Overview + +``` + +------------------+ + | LLM Agents | + | (Claude, Cursor, | + | Codex, etc.) | + +--------+---------+ + | + +--------------+--------------+ + | | + +------v------+ +--------v--------+ + | MCP Server | | JSONL Server | + | (server.py) | | (jsonl_server.py)| + | FastMCP/stdio| | stdin/stdout | + +------+------+ +--------+--------+ + | | + +--------------+--------------+ + | + +--------v--------+ + | Business Logic | + | proposals.py | + | lifecycle.py | + | sessions.py | + | context.py | + +--------+--------+ + | + +--------------+--------------+ + | | + +------v------+ +--------v--------+ + | Storage | | Index (SQLite) | + | (storage.py)| | (index_db.py) | + | YAML/MD on | | FTS5 + embedding| + | disk | | state.db | + +------+------+ +--------+--------+ + | | + +------v------+ +--------v--------+ + | Audit Log | | Embeddings | + | (audit.py) | | (embeddings/) | + | JSONL append| | pluggable models| + +-------------+ +-----------------+ + + +------------------+ + | CLI (cli.py) | + | Click commands | + | Human review | + +------------------+ +``` + +**Core principle:** Agents *propose*, humans *approve*. Files on disk are the source of truth. SQLite is a derived cache. Everything is audited. + +--- + +## Module Map + +### `src/vouch/` — Core Package (27 files, ~4,500 LOC) + +| Module | Purpose | Key Classes/Functions | +|---|---|---| +| `__init__.py` | Package root, version | `__version__` | +| `models.py` | Pydantic domain models (AKBP v0.1 compatible) | `Source`, `Evidence`, `Claim`, `Entity`, `Relation`, `Page`, `Session`, `AuditEvent`, `Proposal`, `ContextPack`, `Capabilities` | +| `storage.py` | File-backed CRUD (YAML/MD on disk) | `KBStore`, `discover_root()`, `ArtifactNotFoundError`, `KBNotFoundError` | +| `server.py` | MCP server (FastMCP over stdio) | `mcp`, `kb_*` tool functions, `run_stdio()` | +| `jsonl_server.py` | JSONL tool server (stdin/stdout) | `HANDLERS` dict, `handle_request()`, `run_jsonl()` | +| `cli.py` | Click CLI for humans | `cli` group, 30+ commands | +| `proposals.py` | Review gate: propose -> approve/reject | `propose_claim()`, `propose_page()`, `propose_entity()`, `propose_relation()`, `approve()`, `reject()` | +| `lifecycle.py` | Claim lifecycle mutations (direct, audited) | `supersede()`, `contradict()`, `archive()`, `confirm()`, `cite()` | +| `sessions.py` | Agent session lifecycle | `session_start()`, `session_end()`, `crystallize()` | +| `context.py` | ContextPack assembly for agent prompts | `build_context_pack()` | +| `index_db.py` | SQLite FTS5 index + embedding storage | `open_db()`, `search()`, `search_semantic()`, `search_embedding()` | +| `audit.py` | Append-only JSONL audit log | `log_event()`, `read_events()` | +| `health.py` | Status, lint, doctor, index rebuild | `status()`, `lint()`, `doctor()`, `rebuild_index()` | +| `verify.py` | Source integrity verification (hash drift) | `verify_source()`, `verify_all()` | +| `bundle.py` | Portable tar.gz export/import with manifest | `export()`, `import_check()`, `import_apply()`, `export_check()` | +| `capabilities.py` | AKBP capabilities descriptor | `capabilities()`, `METHODS` list | +| `onboarding.py` | Starter KB content for `vouch init` | `seed_starter_kb()` | + +### `src/vouch/embeddings/` — Semantic Retrieval (12 files) + +| Module | Purpose | +|---|---| +| `__init__.py` | Registry, auto-discovers adapters | +| `base.py` | `Embedder` protocol, `register()` / `get_embedder()` registry | +| `st_mpnet.py` | sentence-transformers `all-mpnet-base-v2` adapter (default) | +| `st_minilm.py` | sentence-transformers `all-MiniLM-L6-v2` adapter | +| `fastembed_bge.py` | FastEmbed BGE adapter (ONNX-backed) | +| `cache.py` | Query embedding cache (SQLite-backed) | +| `fusion.py` | Reciprocal Rank Fusion (RRF) for hybrid search | +| `hyde.py` | Hypothetical Document Embedding query expansion | +| `rerank.py` | Cross-encoder reranking | +| `dedup.py` | Near-duplicate detection via cosine similarity | +| `scorer.py` | Retrieval evaluation (recall@k, MRR, NDCG) | +| `migration.py` | Embedding model migration / backfill | + +--- + +## Object Model + +``` +Source (immutable, content-addressed by sha256) + | + +-- Evidence (span pointer: line range, timestamp, quote) + | + +-- Claim (atomic assertion, typed, status-tracked, confidence-scored) + | |-- cites: [Source/Evidence ids] (mandatory, >=1) + | |-- status: working -> actionable -> stable -> contested/superseded/archived + | |-- approved_by: review gate audit trail + | +-- type: fact | decision | preference | workflow | observation | question | warning + | + +-- Entity (typed node: person, project, repo, concept, ...) + | + +-- Relation (typed edge: uses, depends_on, supersedes, contradicts, ...) + | + +-- Page (markdown document with YAML frontmatter, links claims/entities) + | + +-- Session (agent work block, bundles proposals) + | + +-- Proposal (pending write, gated by review) + | |-- kind: claim | page | entity | relation + | +-- status: pending -> approved | rejected + | + +-- AuditEvent (append-only log entry for every mutation) + | + +-- ContextPack (retrieval result bundle with quality gate) +``` + +### Enums + +| Enum | Values | +|---|---| +| `SourceType` | file, url, transcript, message, commit, issue, screenshot, pdf, audio, video, folder | +| `ClaimType` | fact, decision, preference, workflow, observation, question, warning | +| `ClaimStatus` | working, actionable, stable, contested, superseded, archived, redacted | +| `EntityType` | person, project, repo, company, concept, decision, workflow, file, api, incident, source, agent, tool, team, system | +| `RelationType` | uses, depends_on, contradicts, supersedes, supports, caused_by, owned_by, derived_from, similar_to, blocks, implements, references | +| `PageType` | entity, concept, decision, workflow, session, index, log, report, source-summary | +| `Scope` | private, project, team, public | + +--- + +## Data Flow + +### Write Path (Review-Gated) + +``` +Agent calls kb_propose_claim(text, evidence, ...) + | + v +proposals.py: propose_claim() + |-- validates evidence ids exist (Source or Evidence) + |-- generates slug from text + |-- creates Proposal(kind=CLAIM, status=PENDING) + |-- storage.put_proposal() -> .vouch/proposed/.yaml (gitignored) + |-- audit.log_event("proposal.claim.create") + v +Human runs `vouch approve ` + | + v +proposals.py: approve() + |-- checks proposal is PENDING + |-- checks approved_by != proposed_by (unless trusted-agent config) + |-- checks no existing artifact with that id + |-- creates Claim from payload + |-- storage.put_claim() -> .vouch/claims/.yaml (committed) + |-- index_db.index_claim() -> state.db FTS5 + |-- storage._embed_and_store() -> state.db embedding_index + |-- moves proposal: proposed/ -> decided/ + |-- audit.log_event("proposal.claim.approve") +``` + +### Read Path (Unrestricted) + +``` +Agent calls kb_search(query, backend="auto") + | + v +1. Try semantic search (index_db.search_semantic) + |-- embeddings.get_embedder().encode(query) + |-- index_db.search_embedding() via sqlite-vec or NumPy cosine + | +2. Fallback: FTS5 search (index_db.search) + |-- BM25 ranking across claims_fts, pages_fts, entities_fts + | +3. Fallback: substring scan (storage.search_substring) + |-- brute-force text match across all artifacts +``` + +### Session + Crystallize Flow + +``` +kb_session_start(task="...") + |-> Session record in .vouch/sessions/ + +Agent proposes claims/pages/entities during session + |-> each Proposal gets session_id + +kb_session_end(session_id) + |-> backfills proposal_ids + +kb_crystallize(session_id) + |-> approve() every PENDING proposal in session + |-> write session-summary Page + |-> audit everything +``` + +--- + +## Storage Layout + +``` +/ + .vouch/ + |-- config.yaml # KB settings (version, review policy, retrieval config) + |-- .gitignore # ignores proposed/, state.db + |-- audit.log.jsonl # append-only audit (committed) + |-- state.db # SQLite: FTS5 + embeddings (derived, gitignored) + |-- claims/.yaml # durable approved claims + |-- pages/.md # markdown pages with YAML frontmatter + |-- sources// + | |-- meta.yaml # source metadata + | +-- content # raw source bytes + |-- entities/.yaml # graph nodes + |-- relations/.yaml # graph edges + |-- evidence/.yaml # citation pointers into sources + |-- sessions/.yaml # session records + |-- proposed/.yaml # pending proposals (gitignored, local-only) + +-- decided/.yaml # approved/rejected proposals (committed) +``` + +**Key invariant:** Files are the source of truth. `state.db` is a derived cache rebuilt by `vouch index`. Losing it is never fatal. + +--- + +## Transport Surfaces + +### 1. MCP Server (`server.py`) + +- Transport: stdio (FastMCP) +- Entry: `vouch serve` or configured in `.mcp.json` +- Agent identification: `VOUCH_AGENT` env var +- 43 tools registered via `@mcp.tool()` decorators + +### 2. JSONL Server (`jsonl_server.py`) + +- Transport: newline-delimited JSON over stdin/stdout +- Entry: `vouch serve --transport jsonl` +- Same tool surface as MCP, different wire format +- Request: `{"id": "r1", "method": "kb.search", "params": {...}}` +- Response: `{"id": "r1", "ok": true, "result": {...}}` + +### 3. CLI (`cli.py`) + +- Framework: Click +- Entry: `vouch` command (registered via `[project.scripts]`) +- Actor: `VOUCH_AGENT` > `VOUCH_USER` > OS username +- Human-facing review interface + +--- + +## CLI Reference + +### Bootstrap +| Command | Description | +|---|---| +| `vouch init [--path P]` | Initialize `.vouch/` KB with starter content | +| `vouch discover [--path P]` | Find nearest `.vouch/` root | +| `vouch capabilities` | Emit JSON capabilities descriptor | + +### Health & Status +| Command | Description | +|---|---| +| `vouch status [--json]` | Artifact counts + pending proposals | +| `vouch lint [--stale-days N]` | Broken citations, stale claims, dangling refs | +| `vouch doctor` | Full sweep: lint + source verify + index check | + +### Review Gate +| Command | Description | +|---|---| +| `vouch pending` | List proposals awaiting review | +| `vouch show ` | Full proposal details | +| `vouch approve [--reason]` | Approve -> durable artifact | +| `vouch reject --reason "..."` | Reject with reason | + +### Propose (CLI shortcuts) +| Command | Description | +|---|---| +| `vouch propose-claim --text ... --source ...` | Propose a claim | +| `vouch propose-page --title ... [--body -]` | Propose a page | +| `vouch propose-entity --name ... --type ...` | Propose an entity | +| `vouch propose-relation --from ... --rel ... --to ...` | Propose a relation | + +### Sources +| Command | Description | +|---|---| +| `vouch source add PATH` | Register file as Source | +| `vouch source verify [--fail-on-issue]` | Re-hash and detect drift | + +### Lifecycle +| Command | Description | +|---|---| +| `vouch supersede OLD NEW` | Mark old claim superseded by new | +| `vouch contradict A B` | Record contradiction (symmetric) | +| `vouch archive CLAIM_ID` | Archive a claim | +| `vouch confirm CLAIM_ID` | Re-confirm (bumps last_confirmed_at) | +| `vouch cite CLAIM_ID` | Resolve citations | + +### Sessions +| Command | Description | +|---|---| +| `vouch session start [--task ...] [--note ...]` | Start agent session | +| `vouch session end SESSION_ID` | End session | +| `vouch crystallize SESSION_ID [--no-page]` | Approve all pending + summary page | + +### Retrieval +| Command | Description | +|---|---| +| `vouch search QUERY [--backend ...] [--rerank] [--hyde]` | Search KB | +| `vouch context TASK [--max-chars N]` | Build ContextPack for agent prompt | +| `vouch index` | Rebuild state.db | +| `vouch reindex [--embeddings] [--backfill]` | Rebuild FTS5 + optional embedding backfill | +| `vouch dedup [--threshold 0.95]` | Near-duplicate scan | + +### Embeddings +| Command | Description | +|---|---| +| `vouch embeddings stats` | Model identity, counts, cache stats | +| `vouch eval embedding --queries FILE` | Retrieval quality metrics | + +### Audit +| Command | Description | +|---|---| +| `vouch audit [--tail N] [--json]` | Read audit log | + +### Export / Import +| Command | Description | +|---|---| +| `vouch export --out path.tar.gz` | Bundle KB into portable archive | +| `vouch export-check path.tar.gz` | Verify bundle integrity | +| `vouch import-check path.tar.gz` | Diff bundle against destination | +| `vouch import-apply path.tar.gz [--on-conflict ...]` | Apply bundle | + +### Server +| Command | Description | +|---|---| +| `vouch serve [--transport stdio\|jsonl]` | Start MCP or JSONL server | + +--- + +## MCP / JSONL Tool Surface + +43 methods organized by intent: + +### Read (unrestricted) +`kb_capabilities`, `kb_status`, `kb_search`, `kb_context`, `kb_read_page`, `kb_read_claim`, `kb_read_entity`, `kb_read_relation`, `kb_list_pages`, `kb_list_claims`, `kb_list_entities`, `kb_list_relations`, `kb_list_sources`, `kb_list_pending` + +### Source Intake (not gated) +`kb_register_source`, `kb_register_source_from_path`, `kb_source_verify` + +### Write (gated -> proposals) +`kb_propose_claim`, `kb_propose_page`, `kb_propose_entity`, `kb_propose_relation` + +### Decisions +`kb_approve`, `kb_reject` + +### Lifecycle (direct mutation, audited) +`kb_supersede`, `kb_contradict`, `kb_archive`, `kb_confirm`, `kb_cite` + +### Sessions +`kb_session_start`, `kb_session_end`, `kb_crystallize` + +### Maintenance +`kb_index_rebuild`, `kb_lint`, `kb_doctor`, `kb_audit`, `kb_export`, `kb_export_check`, `kb_import_check`, `kb_import_apply` + +### Embeddings +`kb_reindex_embeddings`, `kb_dedup_scan`, `kb_eval_embeddings`, `kb_embeddings_stats` + +--- + +## Embeddings Stack + +Pluggable adapter architecture with auto-registration: + +``` +embeddings/ + base.py -- Embedder protocol + registry (register / get_embedder) + st_mpnet.py -- all-mpnet-base-v2 (default, pip install vouch-kb[embeddings]) + st_minilm.py -- all-MiniLM-L6-v2 (lighter alternative) + fastembed_bge.py -- BGE via fastembed/ONNX (pip install vouch-kb[embeddings-fast]) +``` + +### Search dispatch order +1. **Embedding** (semantic) -- sqlite-vec ANN or NumPy cosine fallback +2. **FTS5** -- BM25 over tokenized text +3. **Substring** -- brute-force text match (always available) +4. **Hybrid** -- RRF fusion of embedding + FTS5 + +### Advanced retrieval features +- **Query embedding cache** -- avoids re-encoding repeated queries +- **HyDE** -- hypothetical document expansion before encoding +- **Reranking** -- cross-encoder reranking of candidate hits +- **Dedup** -- cosine-based near-duplicate detection with audit logging +- **Evaluation** -- recall@k, MRR, NDCG via JSONL query sets + +--- + +## Test Structure + +### `tests/` (27 files) + +| Test File | Covers | +|---|---| +| `test_storage.py` | KBStore CRUD, init, search, path safety | +| `test_cli.py` | CLI commands end-to-end (Click test runner) | +| `test_audit.py` | Audit log write/read, malformed line handling | +| `test_bundle.py` | Export/import, manifest integrity, path traversal | +| `test_capabilities.py` | Capabilities descriptor, method list sync | +| `test_context.py` | ContextPack assembly, budget enforcement | +| `test_health.py` | Status, lint findings, doctor, index rebuild | +| `test_index.py` | FTS5 indexing and search | +| `test_jsonl_server.py` | JSONL envelope handling, all methods | +| `test_onboarding.py` | Starter KB seeding | +| `test_sessions.py` | Session lifecycle, crystallize | +| `test_verify.py` | Source verification, drift detection | + +### `tests/embeddings/` (12 files) + +| Test File | Covers | +|---|---| +| `test_core.py` | Embedder registry, encode/decode | +| `test_cli.py` | Embedding CLI commands | +| `test_dedup.py` | Near-duplicate scanning | +| `test_fusion.py` | RRF fusion | +| `test_hyde.py` | HyDE query expansion | +| `test_integration.py` | End-to-end semantic search (marked `integration`) | +| `test_migration.py` | Model migration, backfill | +| `test_rerank.py` | Cross-encoder reranking | +| `test_scorer.py` | Retrieval evaluation metrics | +| `test_search.py` | Semantic search paths | +| `test_storage.py` | Embedding storage in SQLite | +| `conftest.py` / `_fakes.py` | Shared fixtures, fake embedder | + +### Running tests +```bash +pytest # unit tests (fast, no model loading) +pytest -m integration # integration tests (loads real embedding model) +``` + +--- + +## CI/CD + +### `.github/workflows/` + +| Workflow | Purpose | +|---|---| +| `ci.yml` | Lint (ruff), type check (mypy), test (pytest), coverage | +| `release.yml` | Build + publish to PyPI on release tags | +| `schema-check.yml` | Validate JSON schemas match pydantic models | + +### Quality gates +- **Ruff** -- linter (E, F, I, B, UP, SIM, RUF rules, line-length 100) +- **Mypy** -- strict type checking (numpy/sentence-transformers/fastembed stubs ignored) +- **Pytest** -- unit tests by default; integration tests in separate job +- **Pre-commit** -- configured via `.pre-commit-config.yaml` + +--- + +## Supporting Directories + +| Directory | Purpose | Audience | +|---|---|---| +| `docs/` | User-facing documentation (9 guides + banner/demo) | End users, operators | +| `spec/` | Normative specification snapshots | Implementers, auditors | +| `schemas/` | 15 JSON Schemas (Draft 2020-12, generated from pydantic) | Tooling, validation | +| `proposals/` | VEP (Vouch Enhancement Proposals) | Protocol evolution | +| `templates/` | Copy-paste YAML/MD templates for every artifact type | Learning | +| `examples/` | Example KB layouts (tiny, decision-log) | Learning | +| `adapters/` | Per-runtime wiring guides (Claude Code, Cursor, Codex, Continue, generic MCP, JSONL shell) | Integration | +| `benchmarks/` | Benchmark fixtures and harness | Performance testing | +| `scripts/` | `gen_schemas.py` -- generate JSON schemas from models | Development | + +--- + +## Dependencies + +### Core (required) +| Package | Version | Purpose | +|---|---|---| +| pydantic | >=2.13.4, <3 | Domain models, validation | +| click | >=8.4.0, <9 | CLI framework | +| pyyaml | >=6, <7 | YAML serialization | +| mcp | >=1.0, <2 | MCP server SDK | + +### Optional extras +| Extra | Packages | Purpose | +|---|---|---| +| `[embeddings]` | sentence-transformers, numpy | Semantic search (mpnet/minilm) | +| `[embeddings-fast]` | fastembed, onnxruntime, numpy, sqlite-vec | Semantic search (BGE, ONNX) | +| `[rerank]` | sentence-transformers | Cross-encoder reranking | +| `[dev]` | pytest, pytest-cov, mypy, ruff, types-pyyaml | Development | + +--- + +## Codebase Statistics + +| Metric | Count | +|---|---| +| Total Python LOC | ~8,400 | +| Source modules | 27 | +| Test modules | 27 | +| CLI commands | 30+ | +| MCP/JSONL tools | 43 | +| Pydantic models | 15 | +| JSON schemas | 15 | +| Embedding adapters | 3 | +| CI workflows | 3 | +| Documentation guides | 9 | + +--- + +*Generated 2026-05-28 from source analysis of vouch v0.1.0 on branch `release/0.1.0`.* From bbd59f9b1cb4784736dd4b67de3ca2d291255ba6 Mon Sep 17 00:00:00 2001 From: John Baillie Date: Tue, 26 May 2026 14:31:45 -0400 Subject: [PATCH 027/149] feat(health): add vouch fsck for deep consistency checks Adds a `vouch fsck` command that runs deeper integrity checks than `vouch doctor`: - orphaned embeddings (rows in `embeddings` / `embedding_index` for artifacts that no longer exist on disk) - dangling supersede / contradict chains (claim.supersedes, superseded_by, contradicts pointing at missing claims; asymmetric contradicts edges) - decided proposals whose approved artifact is missing on disk - FTS5 index drift: orphan rows, missing rows, and the `claims_fts.status` drift shape from #78 Read-only; reports findings with object ids. `--fix` is intentionally out of scope for v1 per the acceptance criteria. Closes #96 Co-Authored-By: Claude Opus 4.7 (1M context) --- CHANGELOG.md | 7 ++ README.md | 1 + src/vouch/cli.py | 15 ++++ src/vouch/health.py | 183 ++++++++++++++++++++++++++++++++++++++++++- tests/test_cli.py | 21 +++++ tests/test_health.py | 153 +++++++++++++++++++++++++++++++++++- 6 files changed, 377 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6d4fff95..16df88ba 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,13 @@ All notable changes to vouch are documented here. Format follows ## [Unreleased] +### Added +- `vouch fsck` performs deep consistency checks beyond `vouch doctor`: + orphaned embeddings, dangling supersede/contradict chains, decided + proposals whose artifact is missing, and FTS5 index-vs-file drift + (orphan rows, missing rows, status drift). Read-only; reports findings + with object ids. `--fix` is intentionally out of scope (#96). + ## [0.1.0] — 2026-05-26 ### Packaging diff --git a/README.md b/README.md index a3f298c9..435aa52e 100644 --- a/README.md +++ b/README.md @@ -143,6 +143,7 @@ vouch capabilities # emit the JSON capabilities descrip vouch status [--json] # KB counts + pending proposals vouch lint [--stale-days N] # user-actionable problems vouch doctor # full sweep incl. source verification +vouch fsck # deep consistency: indexes, lifecycle, decided vouch pending # list pending proposals vouch show diff --git a/src/vouch/cli.py b/src/vouch/cli.py index 100dcdce..b4fff865 100644 --- a/src/vouch/cli.py +++ b/src/vouch/cli.py @@ -185,6 +185,21 @@ def doctor() -> None: sys.exit(0 if report.ok else 1) +@cli.command() +def fsck() -> None: + """Deep consistency check: orphan embeddings, dangling supersede/contradict + chains, decided-proposal ↔ artifact mismatches, index-vs-file drift. + """ + store = _load_store() + report = health.fsck(store) + for f in report.findings: + marker = {"error": "✗", "warning": "!", "info": "·"}.get(f.severity, "?") + click.echo(f"{marker} [{f.code}] {f.message}") + if not report.findings: + click.echo("clean") + sys.exit(0 if report.ok else 1) + + # --- proposals ------------------------------------------------------------ diff --git a/src/vouch/health.py b/src/vouch/health.py index ac3fe510..d78fd56a 100644 --- a/src/vouch/health.py +++ b/src/vouch/health.py @@ -13,7 +13,7 @@ from . import index_db from .audit import count_events -from .models import ClaimStatus, ProposalStatus +from .models import Claim, ClaimStatus, Entity, Page, ProposalKind, ProposalStatus from .storage import KBStore, sha256_hex from .verify import verify_all @@ -145,6 +145,187 @@ def doctor(store: KBStore) -> HealthReport: return report +def fsck(store: KBStore) -> HealthReport: + """Deep consistency check — orphaned embeddings, dangling lifecycle + chains, decided-proposal ↔ artifact mismatches, index-vs-file drift. + + Read-only; report findings only. `--fix` is intentionally out of scope. + """ + findings: list[Finding] = [] + claims: dict[str, Claim] = {c.id: c for c in store.list_claims()} + pages: dict[str, Page] = {p.id: p for p in store.list_pages()} + entities: dict[str, Entity] = {e.id: e for e in store.list_entities()} + + _check_lifecycle_chains(claims, findings) + _check_decided_proposals(store, claims, pages, entities, findings) + + db_present = (store.kb_dir / index_db.DB_FILENAME).exists() + if not db_present: + findings.append(Finding( + "info", "index_missing", + "state.db not present — run `vouch index` to build it", + )) + else: + _check_index_drift(store, claims, pages, entities, findings) + _check_orphan_embeddings(store, claims, pages, entities, findings) + + ok = not any(f.severity == "error" for f in findings) + return HealthReport(ok=ok, findings=findings, counts=status(store)) + + +def _check_lifecycle_chains( + claims: dict[str, Claim], findings: list[Finding], +) -> None: + for cid, c in claims.items(): + for target in c.supersedes: + if target not in claims: + findings.append(Finding( + "error", "dangling_supersedes", + f"claim {cid} supersedes missing claim {target}", + [cid, target], + )) + if c.superseded_by is not None and c.superseded_by not in claims: + findings.append(Finding( + "error", "dangling_superseded_by", + f"claim {cid} superseded_by missing claim {c.superseded_by}", + [cid, c.superseded_by], + )) + for other in c.contradicts: + if other not in claims: + findings.append(Finding( + "error", "dangling_contradicts", + f"claim {cid} contradicts missing claim {other}", + [cid, other], + )) + continue + if cid not in claims[other].contradicts: + findings.append(Finding( + "warning", "asymmetric_contradicts", + f"claim {cid} contradicts {other} but {other} does not " + f"contradict {cid} back", + [cid, other], + )) + + +def _check_decided_proposals( + store: KBStore, + claims: dict[str, Claim], + pages: dict[str, Page], + entities: dict[str, Entity], + findings: list[Finding], +) -> None: + relations = {r.id for r in store.list_relations()} + presence: dict[ProposalKind, set[str]] = { + ProposalKind.CLAIM: set(claims), + ProposalKind.PAGE: set(pages), + ProposalKind.ENTITY: set(entities), + ProposalKind.RELATION: relations, + } + for pr in store.list_proposals(ProposalStatus.APPROVED): + artifact_id = pr.payload.get("id") if isinstance(pr.payload, dict) else None + if not artifact_id: + findings.append(Finding( + "error", "decided_no_artifact_id", + f"approved proposal {pr.id} has no payload id", + [pr.id], + )) + continue + if artifact_id not in presence[pr.kind]: + findings.append(Finding( + "error", "decided_missing_artifact", + f"approved proposal {pr.id} promised " + f"{pr.kind.value} {artifact_id} but artifact is missing", + [pr.id, artifact_id], + )) + + +def _check_index_drift( + store: KBStore, + claims: dict[str, Claim], + pages: dict[str, Page], + entities: dict[str, Entity], + findings: list[Finding], +) -> None: + with index_db.open_db(store.kb_dir) as conn: + indexed_claims = { + (row[0], row[1]) for row in + conn.execute("SELECT id, status FROM claims_fts").fetchall() + } + indexed_pages = {row[0] for row in + conn.execute("SELECT id FROM pages_fts").fetchall()} + indexed_entities = {row[0] for row in + conn.execute("SELECT id FROM entities_fts").fetchall()} + + indexed_claim_ids = {cid for cid, _ in indexed_claims} + for cid, status_in_index in indexed_claims: + if cid not in claims: + findings.append(Finding( + "error", "index_orphan_claim", + f"claims_fts row {cid} has no claim on disk", + [cid], + )) + continue + on_disk = claims[cid].status.value + if status_in_index != on_disk: + findings.append(Finding( + "error", "index_status_drift", + f"claim {cid} status on disk is {on_disk!r} but " + f"claims_fts has {status_in_index!r}", + [cid], + )) + for cid in claims.keys() - indexed_claim_ids: + findings.append(Finding( + "error", "index_missing_row", + f"claim {cid} on disk but missing from claims_fts", + [cid], + )) + for pid in indexed_pages - pages.keys(): + findings.append(Finding( + "error", "index_orphan_page", + f"pages_fts row {pid} has no page on disk", [pid], + )) + for pid in pages.keys() - indexed_pages: + findings.append(Finding( + "error", "index_missing_row", + f"page {pid} on disk but missing from pages_fts", [pid], + )) + for eid in indexed_entities - entities.keys(): + findings.append(Finding( + "error", "index_orphan_entity", + f"entities_fts row {eid} has no entity on disk", [eid], + )) + for eid in entities.keys() - indexed_entities: + findings.append(Finding( + "error", "index_missing_row", + f"entity {eid} on disk but missing from entities_fts", [eid], + )) + + +def _check_orphan_embeddings( + store: KBStore, + claims: dict[str, Claim], + pages: dict[str, Page], + entities: dict[str, Entity], + findings: list[Finding], +) -> None: + presence = {"claim": set(claims), "page": set(pages), "entity": set(entities)} + with index_db.open_db(store.kb_dir) as conn: + # Two tables exist: `embeddings` (legacy) and `embedding_index` + # (current). Check both — either one drifting silently breaks + # semantic retrieval. + for table in ("embeddings", "embedding_index"): + rows = conn.execute(f"SELECT kind, id FROM {table}").fetchall() + for kind, eid in rows: + live = presence.get(kind) + if live is None or eid in live: + continue + findings.append(Finding( + "warning", "orphan_embedding", + f"{table} row for {kind} {eid} has no artifact on disk", + [eid], + )) + + def rebuild_index(store: KBStore) -> dict: """Drop and rebuild state.db from the durable files. Idempotent.""" # Detect a stale embedding-model identity before reset() wipes the meta. diff --git a/tests/test_cli.py b/tests/test_cli.py index 54661097..a4634033 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -82,6 +82,27 @@ def test_show_missing_proposal_shows_clean_error(store: KBStore) -> None: _assert_clean_error(result, "proposal no-such-proposal") +def test_fsck_clean_kb_prints_clean_and_exits_zero(store: KBStore) -> None: + from vouch.models import Claim + src = store.put_source(b"e") + store.put_claim(Claim(id="c1", text="t", evidence=[src.id])) + result = CliRunner().invoke(cli, ["fsck"]) + # No state.db yet → info finding, but report.ok stays True. + assert result.exit_code == 0, result.output + assert "[index_missing]" in result.output + + +def test_fsck_reports_dangling_chain_and_exits_nonzero(store: KBStore) -> None: + from vouch.models import Claim + src = store.put_source(b"e") + store.put_claim(Claim( + id="c1", text="t", evidence=[src.id], supersedes=["ghost"], + )) + result = CliRunner().invoke(cli, ["fsck"]) + assert result.exit_code == 1, result.output + assert "dangling_supersedes" in result.output + + def test_search_fts5_backend_label( store: KBStore, monkeypatch: pytest.MonkeyPatch ) -> None: diff --git a/tests/test_health.py b/tests/test_health.py index 659b9139..97b8004d 100644 --- a/tests/test_health.py +++ b/tests/test_health.py @@ -6,8 +6,8 @@ import pytest -from vouch import health -from vouch.models import Claim, ClaimStatus, Relation +from vouch import health, index_db +from vouch.models import Claim, ClaimStatus, Proposal, ProposalKind, ProposalStatus, Relation from vouch.storage import KBStore @@ -52,3 +52,152 @@ def test_list_claims_filtered_by_status(store: KBStore) -> None: status=ClaimStatus.ARCHIVED)) stable = [c for c in store.list_claims() if c.status == ClaimStatus.STABLE] assert [c.id for c in stable] == ["c1"] + + +# --- fsck ---------------------------------------------------------------- + + +def _index_claim(store: KBStore, claim: Claim) -> None: + with index_db.open_db(store.kb_dir) as conn: + index_db.index_claim( + conn, id=claim.id, text=claim.text, + type=claim.type.value, status=claim.status.value, tags=claim.tags, + ) + + +def test_fsck_clean_kb_passes(store: KBStore) -> None: + src = store.put_source(b"e") + c = Claim(id="c1", text="t", evidence=[src.id]) + store.put_claim(c) + _index_claim(store, c) + report = health.fsck(store) + assert report.ok is True + assert all(f.severity != "error" for f in report.findings) + + +def test_fsck_flags_dangling_supersedes(store: KBStore) -> None: + src = store.put_source(b"e") + store.put_claim(Claim(id="c1", text="t", evidence=[src.id], + supersedes=["ghost"])) + report = health.fsck(store) + codes = {f.code for f in report.findings} + assert "dangling_supersedes" in codes + assert report.ok is False + + +def test_fsck_flags_dangling_superseded_by(store: KBStore) -> None: + src = store.put_source(b"e") + store.put_claim(Claim(id="c1", text="t", evidence=[src.id], + superseded_by="ghost")) + report = health.fsck(store) + codes = {f.code for f in report.findings} + assert "dangling_superseded_by" in codes + + +def test_fsck_flags_dangling_contradicts(store: KBStore) -> None: + src = store.put_source(b"e") + store.put_claim(Claim(id="c1", text="t", evidence=[src.id], + contradicts=["ghost"])) + report = health.fsck(store) + codes = {f.code for f in report.findings} + assert "dangling_contradicts" in codes + + +def test_fsck_flags_asymmetric_contradicts(store: KBStore) -> None: + src = store.put_source(b"e") + store.put_claim(Claim(id="c1", text="a", evidence=[src.id], + contradicts=["c2"])) + store.put_claim(Claim(id="c2", text="b", evidence=[src.id])) + report = health.fsck(store) + codes = {f.code for f in report.findings} + assert "asymmetric_contradicts" in codes + + +def test_fsck_decided_missing_artifact(store: KBStore) -> None: + store.put_proposal(Proposal( + id="prop-1", + kind=ProposalKind.CLAIM, + proposed_by="agent", + payload={"id": "vanished", "text": "t", "evidence": ["e1"]}, + status=ProposalStatus.APPROVED, + )) + # Move it to decided/ so list_proposals finds it as approved. + src_path = store.kb_dir / "proposed" / "prop-1.yaml" + dst_path = store.kb_dir / "decided" / "prop-1.yaml" + dst_path.write_text(src_path.read_text()) + src_path.unlink() + + report = health.fsck(store) + codes = {f.code for f in report.findings} + assert "decided_missing_artifact" in codes + + +def test_fsck_index_orphan_row(store: KBStore) -> None: + src = store.put_source(b"e") + c = Claim(id="real", text="t", evidence=[src.id]) + store.put_claim(c) + _index_claim(store, c) + # Inject a row for a claim that doesn't exist on disk. + with index_db.open_db(store.kb_dir) as conn: + index_db.index_claim( + conn, id="ghost", text="x", + type="fact", status="working", tags=[], + ) + report = health.fsck(store) + codes = {f.code for f in report.findings} + assert "index_orphan_claim" in codes + + +def test_fsck_index_missing_row(store: KBStore) -> None: + src = store.put_source(b"e") + c = Claim(id="unindexed", text="t", evidence=[src.id]) + store.put_claim(c) + # State.db exists but the row was never written. + with index_db.open_db(store.kb_dir) as _conn: + pass + report = health.fsck(store) + codes = {f.code for f in report.findings} + assert "index_missing_row" in codes + + +def test_fsck_index_status_drift(store: KBStore) -> None: + src = store.put_source(b"e") + c = Claim(id="drifty", text="t", evidence=[src.id], + status=ClaimStatus.STABLE) + store.put_claim(c) + # Index says working, disk says stable — the #78 failure shape. + with index_db.open_db(store.kb_dir) as conn: + index_db.index_claim( + conn, id=c.id, text=c.text, type=c.type.value, + status="working", tags=c.tags, + ) + report = health.fsck(store) + codes = {f.code for f in report.findings} + assert "index_status_drift" in codes + + +def test_fsck_orphan_embedding(store: KBStore) -> None: + src = store.put_source(b"e") + c = Claim(id="real", text="t", evidence=[src.id]) + store.put_claim(c) + _index_claim(store, c) + with index_db.open_db(store.kb_dir) as conn: + index_db.index_embedding(conn, kind="claim", id="ghost", vec=[0.1, 0.2]) + report = health.fsck(store) + codes = {f.code for f in report.findings} + assert "orphan_embedding" in codes + + +def test_fsck_without_state_db_reports_info(store: KBStore) -> None: + src = store.put_source(b"e") + store.put_claim(Claim(id="c1", text="t", evidence=[src.id])) + # The embedding write-hook may auto-create state.db on put_claim; this + # test verifies the explicit "no index yet" path. + db_path = store.kb_dir / index_db.DB_FILENAME + if db_path.exists(): + db_path.unlink() + report = health.fsck(store) + codes = {f.code for f in report.findings} + assert "index_missing" in codes + # info finding alone shouldn't fail the report. + assert report.ok is True From 503218b75c9f0afc755e28dcb8960156260428b1 Mon Sep 17 00:00:00 2001 From: John Baillie Date: Tue, 26 May 2026 14:41:38 -0400 Subject: [PATCH 028/149] fsck: surface object_ids inline, list fsck in 'What ships today' Addresses review comments on #112: - CLI fsck output now appends `(objects: id1, id2)` when a finding has structured object_ids, so users can grep / pipe straight to inspection. - The 'What ships today' CLI inventory in README.md now lists `fsck` alongside `doctor`, matching the cheatsheet earlier in the file. Co-Authored-By: Claude Opus 4.7 (1M context) --- README.md | 2 +- src/vouch/cli.py | 5 ++++- tests/test_cli.py | 2 ++ 3 files changed, 7 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 435aa52e..c4ff4f9f 100644 --- a/README.md +++ b/README.md @@ -255,7 +255,7 @@ vouch import-apply kb.tar.gz --on-conflict skip # apply (default skip; never de | Area | Current support | |------|-----------------| | Knowledge base | `.vouch/` folder, YAML claims/entities/relations/evidence/sessions, markdown pages with frontmatter, JSONL audit log, content-addressed sources | -| CLI | `init`, `discover`, `capabilities`, `status`, `lint`, `doctor`, `pending`, `show`, `approve`, `reject`, `propose-{claim,page,entity,relation}`, `source add`, `source verify`, `supersede`, `contradict`, `archive`, `confirm`, `cite`, `session {start,end}`, `crystallize`, `search`, `context`, `index`, `audit`, `export`, `export-check`, `import-check`, `import-apply`, `serve` | +| CLI | `init`, `discover`, `capabilities`, `status`, `lint`, `doctor`, `fsck`, `pending`, `show`, `approve`, `reject`, `propose-{claim,page,entity,relation}`, `source add`, `source verify`, `supersede`, `contradict`, `archive`, `confirm`, `cite`, `session {start,end}`, `crystallize`, `search`, `context`, `index`, `audit`, `export`, `export-check`, `import-check`, `import-apply`, `serve` | | Tool servers | MCP over stdio + JSONL over stdin/stdout, same `kb.*` surface across both transports, capabilities + knowledge-capability descriptor | | Schemas | 13 JSON Schemas (Draft 2020-12) generated from pydantic in [schemas/](schemas/), plus hand-maintained `bundle.manifest` and `jsonl-envelope` schemas | | Write safety | review-gated writes via [proposed/](spec/review-gate.md), `dry_run:true` previews, host trust required for `approve`/`reject`, atomic exclusive-create storage, path-traversal blocked on source intake and bundle import | diff --git a/src/vouch/cli.py b/src/vouch/cli.py index b4fff865..4c76652d 100644 --- a/src/vouch/cli.py +++ b/src/vouch/cli.py @@ -194,7 +194,10 @@ def fsck() -> None: report = health.fsck(store) for f in report.findings: marker = {"error": "✗", "warning": "!", "info": "·"}.get(f.severity, "?") - click.echo(f"{marker} [{f.code}] {f.message}") + line = f"{marker} [{f.code}] {f.message}" + if f.object_ids: + line += f" (objects: {', '.join(f.object_ids)})" + click.echo(line) if not report.findings: click.echo("clean") sys.exit(0 if report.ok else 1) diff --git a/tests/test_cli.py b/tests/test_cli.py index a4634033..f135e857 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -101,6 +101,8 @@ def test_fsck_reports_dangling_chain_and_exits_nonzero(store: KBStore) -> None: result = CliRunner().invoke(cli, ["fsck"]) assert result.exit_code == 1, result.output assert "dangling_supersedes" in result.output + # The affected object ids are surfaced inline so users can grep / pipe. + assert "(objects: c1, ghost)" in result.output def test_search_fts5_backend_label( From 297cafc46f3b1aa6474acbb9c097ad4fb9fbd41b Mon Sep 17 00:00:00 2001 From: John Baillie Date: Tue, 26 May 2026 14:47:40 -0400 Subject: [PATCH 029/149] fsck: add docstrings to private helpers and new tests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CodeRabbit's pre-merge docstring-coverage check was at 23% for this change set. Add concise docstrings to: - the four `_check_*` helpers in `health.py` — each one names *why* the check exists (the failure mode it catches), not just what it does - the `_index_claim` test helper and the eleven new `test_fsck_*` tests — one-line summaries of what each test asserts No behavior change. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/vouch/health.py | 25 +++++++++++++++++++++++++ tests/test_cli.py | 2 ++ tests/test_health.py | 12 ++++++++++++ 3 files changed, 39 insertions(+) diff --git a/src/vouch/health.py b/src/vouch/health.py index d78fd56a..09a6c4ea 100644 --- a/src/vouch/health.py +++ b/src/vouch/health.py @@ -176,6 +176,12 @@ def fsck(store: KBStore) -> HealthReport: def _check_lifecycle_chains( claims: dict[str, Claim], findings: list[Finding], ) -> None: + """Detect supersede / contradict pointers into the void or out-of-sync. + + Each claim records its lifecycle links inline (`supersedes`, + `superseded_by`, `contradicts`). Those lists can drift if a referenced + claim was deleted or if a `contradict` was only written from one side. + """ for cid, c in claims.items(): for target in c.supersedes: if target not in claims: @@ -214,6 +220,12 @@ def _check_decided_proposals( entities: dict[str, Entity], findings: list[Finding], ) -> None: + """Every approved proposal should have its artifact on disk. + + A crash between `put_()` and `move_proposal_to_decided()` would + leave a `decided/` entry without a matching artifact (or vice versa); + surface the artifact-missing case so an operator can investigate. + """ relations = {r.id for r in store.list_relations()} presence: dict[ProposalKind, set[str]] = { ProposalKind.CLAIM: set(claims), @@ -246,6 +258,13 @@ def _check_index_drift( entities: dict[str, Entity], findings: list[Finding], ) -> None: + """FTS5 must match disk for every searchable artifact. + + Three drift shapes matter: an indexed row whose artifact is gone, an + artifact missing from the index entirely (write-hook failure), and a + `claims_fts.status` value that disagrees with the on-disk + `claim.status` (the #78 failure mode that leaks archived claims). + """ with index_db.open_db(store.kb_dir) as conn: indexed_claims = { (row[0], row[1]) for row in @@ -308,6 +327,12 @@ def _check_orphan_embeddings( entities: dict[str, Entity], findings: list[Finding], ) -> None: + """Flag embedding rows whose artifact has been deleted. + + Stale vectors are silent: semantic search still returns them, snippets + just fall back to the bare id. Both the legacy `embeddings` table and + the newer `embedding_index` table are checked. + """ presence = {"claim": set(claims), "page": set(pages), "entity": set(entities)} with index_db.open_db(store.kb_dir) as conn: # Two tables exist: `embeddings` (legacy) and `embedding_index` diff --git a/tests/test_cli.py b/tests/test_cli.py index f135e857..d1ef4749 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -83,6 +83,7 @@ def test_show_missing_proposal_shows_clean_error(store: KBStore) -> None: def test_fsck_clean_kb_prints_clean_and_exits_zero(store: KBStore) -> None: + """`vouch fsck` on a fresh KB exits 0 and only emits info-level findings.""" from vouch.models import Claim src = store.put_source(b"e") store.put_claim(Claim(id="c1", text="t", evidence=[src.id])) @@ -93,6 +94,7 @@ def test_fsck_clean_kb_prints_clean_and_exits_zero(store: KBStore) -> None: def test_fsck_reports_dangling_chain_and_exits_nonzero(store: KBStore) -> None: + """`vouch fsck` exits 1 on error findings and prints affected object ids.""" from vouch.models import Claim src = store.put_source(b"e") store.put_claim(Claim( diff --git a/tests/test_health.py b/tests/test_health.py index 97b8004d..61fe8426 100644 --- a/tests/test_health.py +++ b/tests/test_health.py @@ -58,6 +58,7 @@ def test_list_claims_filtered_by_status(store: KBStore) -> None: def _index_claim(store: KBStore, claim: Claim) -> None: + """Write the FTS5 row for `claim` so fsck sees a healthy index baseline.""" with index_db.open_db(store.kb_dir) as conn: index_db.index_claim( conn, id=claim.id, text=claim.text, @@ -66,6 +67,7 @@ def _index_claim(store: KBStore, claim: Claim) -> None: def test_fsck_clean_kb_passes(store: KBStore) -> None: + """A KB with one consistently-indexed claim is fsck-clean.""" src = store.put_source(b"e") c = Claim(id="c1", text="t", evidence=[src.id]) store.put_claim(c) @@ -76,6 +78,7 @@ def test_fsck_clean_kb_passes(store: KBStore) -> None: def test_fsck_flags_dangling_supersedes(store: KBStore) -> None: + """`claim.supersedes` pointing at a missing claim is an error.""" src = store.put_source(b"e") store.put_claim(Claim(id="c1", text="t", evidence=[src.id], supersedes=["ghost"])) @@ -86,6 +89,7 @@ def test_fsck_flags_dangling_supersedes(store: KBStore) -> None: def test_fsck_flags_dangling_superseded_by(store: KBStore) -> None: + """`claim.superseded_by` pointing at a missing claim is an error.""" src = store.put_source(b"e") store.put_claim(Claim(id="c1", text="t", evidence=[src.id], superseded_by="ghost")) @@ -95,6 +99,7 @@ def test_fsck_flags_dangling_superseded_by(store: KBStore) -> None: def test_fsck_flags_dangling_contradicts(store: KBStore) -> None: + """`claim.contradicts` pointing at a missing claim is an error.""" src = store.put_source(b"e") store.put_claim(Claim(id="c1", text="t", evidence=[src.id], contradicts=["ghost"])) @@ -104,6 +109,7 @@ def test_fsck_flags_dangling_contradicts(store: KBStore) -> None: def test_fsck_flags_asymmetric_contradicts(store: KBStore) -> None: + """A → B contradiction not mirrored by B → A is a warning, not silent.""" src = store.put_source(b"e") store.put_claim(Claim(id="c1", text="a", evidence=[src.id], contradicts=["c2"])) @@ -114,6 +120,7 @@ def test_fsck_flags_asymmetric_contradicts(store: KBStore) -> None: def test_fsck_decided_missing_artifact(store: KBStore) -> None: + """An approved decided proposal whose artifact is gone is reported.""" store.put_proposal(Proposal( id="prop-1", kind=ProposalKind.CLAIM, @@ -133,6 +140,7 @@ def test_fsck_decided_missing_artifact(store: KBStore) -> None: def test_fsck_index_orphan_row(store: KBStore) -> None: + """An FTS5 row with no on-disk claim is reported as an index orphan.""" src = store.put_source(b"e") c = Claim(id="real", text="t", evidence=[src.id]) store.put_claim(c) @@ -149,6 +157,7 @@ def test_fsck_index_orphan_row(store: KBStore) -> None: def test_fsck_index_missing_row(store: KBStore) -> None: + """A claim on disk that never made it into FTS5 is reported.""" src = store.put_source(b"e") c = Claim(id="unindexed", text="t", evidence=[src.id]) store.put_claim(c) @@ -161,6 +170,7 @@ def test_fsck_index_missing_row(store: KBStore) -> None: def test_fsck_index_status_drift(store: KBStore) -> None: + """Regression cover for #78: status on disk vs FTS5 must agree.""" src = store.put_source(b"e") c = Claim(id="drifty", text="t", evidence=[src.id], status=ClaimStatus.STABLE) @@ -177,6 +187,7 @@ def test_fsck_index_status_drift(store: KBStore) -> None: def test_fsck_orphan_embedding(store: KBStore) -> None: + """An embedding row for a kind/id with no artifact on disk is flagged.""" src = store.put_source(b"e") c = Claim(id="real", text="t", evidence=[src.id]) store.put_claim(c) @@ -189,6 +200,7 @@ def test_fsck_orphan_embedding(store: KBStore) -> None: def test_fsck_without_state_db_reports_info(store: KBStore) -> None: + """No state.db → info-level `index_missing`, report stays ok.""" src = store.put_source(b"e") store.put_claim(Claim(id="c1", text="t", evidence=[src.id])) # The embedding write-hook may auto-create state.db on put_claim; this From d12e7ddcc58aff4ab36490912d4334a9d1d5cba8 Mon Sep 17 00:00:00 2001 From: John Baillie Date: Tue, 26 May 2026 14:53:44 -0400 Subject: [PATCH 030/149] test(fsck): assert report.ok is False on dangling_{superseded_by,contradicts} Both tests checked the finding code was emitted but didn't verify the error-severity finding actually flipped the report status. Mirrors the sibling `test_fsck_flags_dangling_supersedes`. Co-Authored-By: Claude Opus 4.7 (1M context) --- tests/test_health.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/tests/test_health.py b/tests/test_health.py index 61fe8426..c3a56784 100644 --- a/tests/test_health.py +++ b/tests/test_health.py @@ -96,6 +96,7 @@ def test_fsck_flags_dangling_superseded_by(store: KBStore) -> None: report = health.fsck(store) codes = {f.code for f in report.findings} assert "dangling_superseded_by" in codes + assert report.ok is False def test_fsck_flags_dangling_contradicts(store: KBStore) -> None: @@ -106,6 +107,7 @@ def test_fsck_flags_dangling_contradicts(store: KBStore) -> None: report = health.fsck(store) codes = {f.code for f in report.findings} assert "dangling_contradicts" in codes + assert report.ok is False def test_fsck_flags_asymmetric_contradicts(store: KBStore) -> None: From ab54194f280b93a8bf69ab5d871291ac4ce9e95d Mon Sep 17 00:00:00 2001 From: galuis116 Date: Mon, 25 May 2026 09:03:06 -0700 Subject: [PATCH 031/149] fix(models): require Claim.evidence to be non-empty at the model layer (#81) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The 'claims must cite sources' guarantee (README §'Why this exists' point 3; CONTRIBUTING §'Things we won't merge') used to live only in proposals.propose_claim, so every other write path silently accepted Claim(evidence=[]) and landed an uncited claim: - store.put_claim direct: existence-check loop iterates zero times. - store.update_claim: writes the YAML without re-validating. - bundle.import_apply via _validate_content: defers to Claim.model_validate, which accepted evidence=[] because the model had no min-length constraint. Add @field_validator('evidence') on Claim — raises ValueError when the list is empty. Closes all three bypass paths in one place. store.update_claim additionally re-validates via Claim.model_validate(claim.model_dump()) before persisting, so in-place mutation (c.evidence = []; store.update_claim(c)) raises before the YAML hits disk — the field validator only fires at construction time, not on attribute assignment. Four regression tests: - test_claim_model_rejects_empty_evidence (tests/test_storage.py) — Claim(evidence=[]) raises pydantic.ValidationError. - test_put_claim_rejects_empty_evidence — store.put_claim raises; no claims/.yaml is written. - test_update_claim_rejects_empty_evidence — in-place mutation + update_claim raises; the on-disk YAML is unchanged. - test_import_rejects_uncited_claim (tests/test_bundle.py) — a schema-valid bundle whose claim YAML has evidence: [] is rejected by import_check (schema validation issue) and import_apply raises before writing. The existing guard in proposals.propose_claim becomes a redundant user-facing error message and is left in place for the friendlier CLI/JSONL error string. No on-disk-layout, schema, or bundle-format change; data the model never should have accepted now raises. --- CHANGELOG.md | 12 ++++++++++++ src/vouch/models.py | 16 +++++++++++++++ src/vouch/storage.py | 6 ++++++ tests/test_bundle.py | 45 +++++++++++++++++++++++++++++++++++++++++++ tests/test_storage.py | 39 +++++++++++++++++++++++++++++++++++++ 5 files changed, 118 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4797af36..6eb35342 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -68,6 +68,18 @@ All notable changes to vouch are documented here. Format follows with between `import_check` and the apply re-open is rejected before anything reaches disk and the audit log does not record a `bundle.import` event. +- `Claim.evidence` now enforces "at least one citation" at the model + layer via a `@field_validator` (#81). Previously the + README-documented guarantee ("Claims must cite sources … a claim + without at least one Source/Evidence id is a validation error") + was enforced only in `proposals.propose_claim`, so every other + write path — direct `store.put_claim`, `store.update_claim`, and + `bundle.import_apply` via `_validate_content` — silently accepted + `evidence: []` and landed an uncited claim. The validator closes + all three paths at once; `store.update_claim` additionally + re-validates via `Claim.model_validate(...)` before persisting so + in-place mutation (`c.evidence = []; store.update_claim(c)`) + also raises before the YAML hits disk. ## [0.0.1] — 2026-05-17 diff --git a/src/vouch/models.py b/src/vouch/models.py index a4d1961b..1b340d85 100644 --- a/src/vouch/models.py +++ b/src/vouch/models.py @@ -185,6 +185,22 @@ class Claim(BaseModel): default_factory=list, description="Source ids OR Evidence ids — both are valid citations", ) + + @field_validator("evidence") + @classmethod + def _at_least_one_citation(cls, v: list[str]) -> list[str]: + # The "claims must cite sources" guarantee (README §"Why this exists" + # point 3; CONTRIBUTING §"Things we won't merge") used to live only + # in proposals.propose_claim, so every other write path — + # store.put_claim, store.update_claim, and bundle.import_apply via + # _validate_content — accepted evidence=[] and silently landed an + # uncited claim. Enforcing on the model closes all paths at once. + if not v: + raise ValueError( + "claim must cite at least one Source or Evidence id " + "(README §'Object model'; CONTRIBUTING §'Things we won't merge')" + ) + return v entities: list[str] = Field(default_factory=list) supersedes: list[str] = Field(default_factory=list) superseded_by: str | None = None diff --git a/src/vouch/storage.py b/src/vouch/storage.py index 797f9a11..6f6a666d 100644 --- a/src/vouch/storage.py +++ b/src/vouch/storage.py @@ -325,6 +325,12 @@ def list_claims(self) -> list[Claim]: def update_claim(self, claim: Claim) -> Claim: if not self._claim_path(claim.id).exists(): raise ArtifactNotFoundError(f"claim {claim.id}") + # Re-validate the in-memory Claim before persisting so model + # invariants (e.g. evidence must be non-empty — see #81) hold + # even when a caller mutated fields in place after get_claim(). + # The Claim model's field validators only run at construction + # time; mutation alone bypasses them unless we round-trip. + Claim.model_validate(claim.model_dump(mode="json")) self._claim_path(claim.id).write_text(_yaml_dump(claim.model_dump(mode="json"))) self._embed_and_store(kind="claim", id=claim.id, text=claim.text) return claim diff --git a/tests/test_bundle.py b/tests/test_bundle.py index 20e8d085..a851204c 100644 --- a/tests/test_bundle.py +++ b/tests/test_bundle.py @@ -299,6 +299,51 @@ def test_import_treats_missing_manifest_sha256_as_mismatch( assert not (store.kb_dir / "claims" / "c1.yaml").exists() +def test_import_rejects_uncited_claim(store: KBStore, tmp_path: Path) -> None: + """Regression for #81: a bundle whose claim YAML has evidence: [] + must be rejected by import_check / import_apply because the Claim + model now enforces the 'must cite at least one' invariant. Before + this fix, _validate_content deferred to pydantic, which accepted + evidence=[] and silently landed an uncited claim.""" + uncited_yaml = ( + b"id: bundle-uncited\n" + b'text: "shipped via bundle, no citations"\n' + b"type: fact\n" + b"status: stable\n" + b"confidence: 1.0\n" + b"evidence: []\n" + ) + bundle_path = tmp_path / "uncited.tar.gz" + manifest = { + "spec": bundle.SPEC_VERSION, + "bundle_id": "deadbeef", + "files": [{ + "path": "claims/bundle-uncited.yaml", + "size": len(uncited_yaml), + "sha256": hashlib.sha256(uncited_yaml).hexdigest(), + }], + "counts": {}, + "safety": {"has_proposed": False, "has_state_db": False, + "has_audit_log": False}, + } + with tarfile.open(bundle_path, "w:gz") as tar: + info = tarfile.TarInfo("claims/bundle-uncited.yaml") + info.size = len(uncited_yaml) + tar.addfile(info, io.BytesIO(uncited_yaml)) + mf_bytes = json.dumps(manifest).encode() + mf_info = tarfile.TarInfo(bundle.MANIFEST_NAME) + mf_info.size = len(mf_bytes) + tar.addfile(mf_info, io.BytesIO(mf_bytes)) + + diff = bundle.import_check(store.kb_dir, bundle_path) + assert not diff.ok + assert any("schema validation failed" in i for i in diff.issues), diff.issues + + with pytest.raises(RuntimeError, match="schema validation failed"): + bundle.import_apply(store.kb_dir, bundle_path) + assert not (store.kb_dir / "claims" / "bundle-uncited.yaml").exists() + + def test_import_check_passes_when_member_matches_manifest( store: KBStore, tmp_path: Path ) -> None: diff --git a/tests/test_storage.py b/tests/test_storage.py index 1f3dd8a2..46eeb73f 100644 --- a/tests/test_storage.py +++ b/tests/test_storage.py @@ -5,6 +5,7 @@ from pathlib import Path import pytest +from pydantic import ValidationError from vouch import audit, lifecycle from vouch.models import ( @@ -108,6 +109,44 @@ def test_claim_can_be_updated(store: KBStore) -> None: assert store.get_claim("c1").status == ClaimStatus.STABLE +def test_claim_model_rejects_empty_evidence() -> None: + """Regression for #81: the 'claims must cite sources' guarantee + (README §'Why this exists' point 3; CONTRIBUTING §'Things we + won't merge') is now enforced on the Claim model itself, so + every write path inherits the check instead of relying on + proposals.propose_claim alone.""" + with pytest.raises(ValidationError, match="cite at least one"): + Claim(id="c1", text="uncited", evidence=[]) + + +def test_put_claim_rejects_empty_evidence(store: KBStore) -> None: + """Regression for #81: store.put_claim is a direct write path + that used to silently accept Claim(evidence=[]) because the + only existence-check loop iterated zero times. The model-level + validator now fires before put_claim is even called.""" + with pytest.raises(ValidationError, match="cite at least one"): + store.put_claim(Claim(id="c1", text="uncited", evidence=[])) + assert not (store.kb_dir / "claims" / "c1.yaml").exists() + + +def test_update_claim_rejects_empty_evidence(store: KBStore) -> None: + """Regression for #81: a previously-cited claim cannot be mutated + down to evidence=[] and silently re-persisted. The model's field + validator only fires at construction time, so update_claim + re-validates via Claim.model_validate(claim.model_dump()) before + writing — otherwise in-place mutation would bypass the gate.""" + 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.evidence = [] # in-place mutation alone doesn't trigger validation + + with pytest.raises(ValidationError, match="cite at least one"): + store.update_claim(c) + assert (store.kb_dir / "claims" / "c1.yaml").read_text() == persisted_before + + # --- pages ---------------------------------------------------------------- From cf648b05943fe363a25ca209c17f7eb4d562e94d Mon Sep 17 00:00:00 2001 From: galuis116 Date: Mon, 25 May 2026 15:57:58 -0700 Subject: [PATCH 032/149] health: lint surfaces legacy uncited claims instead of crashing (#82 review) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The new Claim.evidence min-citation validator (#81) also fires when claims are read back from disk. A KB that has a pre-existing uncited claims/.yaml from before the fix would otherwise crash vouch lint / vouch doctor with a bare pydantic.ValidationError deep in store.list_claims(). Add _load_claims_for_lint(), a per-file iteration that catches pydantic.ValidationError (and any other load error) and surfaces each bad file as a Finding with code='invalid_claim' and an explicit repair hint: 'edit the YAML to add a citation, or delete the file'. lint() also stops calling status() to populate counts — status() calls the strict store.list_claims() which would re-raise on the same files — and builds the counts dict inline from the safely-loaded valid claims. Regression test in tests/test_health.py: - test_lint_surfaces_legacy_uncited_claim_yaml_without_crashing hand-crafts a claims/legacy.yaml with evidence: [] (matches the on-disk shape an older buggy write path would have left), asserts vouch lint runs to completion, surfaces invalid_claim in findings with the repair-hint message, and that the well-formed sibling claim is still discovered. CHANGELOG migration note expanded to describe the repair hint. --- CHANGELOG.md | 10 +++++++- src/vouch/health.py | 61 ++++++++++++++++++++++++++++++++++++++++---- tests/test_health.py | 41 +++++++++++++++++++++++++++++ 3 files changed, 106 insertions(+), 6 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6eb35342..92252864 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -79,7 +79,15 @@ All notable changes to vouch are documented here. Format follows all three paths at once; `store.update_claim` additionally re-validates via `Claim.model_validate(...)` before persisting so in-place mutation (`c.evidence = []; store.update_claim(c)`) - also raises before the YAML hits disk. + also raises before the YAML hits disk. **Migration note:** because + the validator also fires when claims are read back, a KB that + already has an uncited `claims/.yaml` on disk from before this + fix would otherwise crash `vouch lint` / `vouch doctor` with a + `pydantic.ValidationError`. `vouch lint` now iterates `claims/` + per-file and surfaces unparseable / uncited YAMLs as + `invalid_claim` findings ("edit the YAML to add a citation, or + delete the file") instead of bailing out — so existing KBs get a + clean repair list rather than a traceback. ## [0.0.1] — 2026-05-17 diff --git a/src/vouch/health.py b/src/vouch/health.py index 3f9a2d15..8f571021 100644 --- a/src/vouch/health.py +++ b/src/vouch/health.py @@ -12,10 +12,12 @@ from datetime import UTC, datetime, timedelta from pathlib import Path +from pydantic import ValidationError + from . import index_db from .audit import count_events -from .models import ClaimStatus, ProposalStatus -from .storage import KBStore, sha256_hex +from .models import Claim, ClaimStatus, ProposalStatus +from .storage import KBStore, _yaml_load, sha256_hex from .verify import verify_all @@ -51,9 +53,41 @@ def status(store: KBStore) -> dict: } -def lint(store: KBStore, *, stale_after_days: int = 180) -> HealthReport: +def _load_claims_for_lint(store: KBStore) -> tuple[list[Claim], list[Finding]]: + """Iterate `claims/*.yaml` one file at a time so a single invalid + YAML can't crash the whole lint sweep — surface it as a finding + and keep going. This is the repair hint for KBs that have legacy + uncited claims from before the Claim.evidence min-citation + validator landed (#81): `vouch lint` lists them as + `invalid_claim` findings so the user can fix or delete the file + rather than seeing a bare `pydantic.ValidationError` traceback.""" + valid: list[Claim] = [] findings: list[Finding] = [] - claims = store.list_claims() + cdir = store.kb_dir / "claims" + if not cdir.is_dir(): + return valid, findings + for p in sorted(cdir.glob("*.yaml")): + cid = p.stem + try: + valid.append(Claim.model_validate(_yaml_load(p.read_text()))) + except ValidationError as e: + tail = str(e).splitlines()[-1].strip() if str(e) else "validation failed" + findings.append(Finding( + "error", "invalid_claim", + f"claim {cid} ({p}) fails model validation: {tail} — " + "edit the YAML to add a citation, or delete the file", + [cid], + )) + except Exception as e: + findings.append(Finding( + "error", "unreadable_claim", + f"claim {cid} ({p}) could not be loaded: {e}", [cid], + )) + return valid, findings + + +def lint(store: KBStore, *, stale_after_days: int = 180) -> HealthReport: + claims, findings = _load_claims_for_lint(store) sources_present = {s.id for s in store.list_sources()} evidence_present = {e.id for e in store.list_evidence()} @@ -107,7 +141,24 @@ def lint(store: KBStore, *, stale_after_days: int = 180) -> HealthReport: )) ok = not any(f.severity == "error" for f in findings) - return HealthReport(ok=ok, findings=findings, counts=status(store)) + # Build counts inline rather than calling status(), because status() + # calls store.list_claims() which is strict and would re-raise on the + # same invalid YAMLs we just surfaced as findings. Use the safely- + # loaded `claims` list so the report is self-consistent. + counts = { + "kb_dir": str(store.kb_dir), + "claims": len(claims), + "pages": len(store.list_pages()), + "sources": len(sources_present), + "entities": len(store.list_entities()), + "relations": len(store.list_relations()), + "evidence": len(evidence_present), + "sessions": len(store.list_sessions()), + "pending_proposals": len(store.list_proposals(ProposalStatus.PENDING)), + "audit_events": count_events(store.kb_dir), + "index_present": (store.kb_dir / index_db.DB_FILENAME).exists(), + } + return HealthReport(ok=ok, findings=findings, counts=counts) def doctor( diff --git a/tests/test_health.py b/tests/test_health.py index 659b9139..ee4c356a 100644 --- a/tests/test_health.py +++ b/tests/test_health.py @@ -44,6 +44,47 @@ def test_doctor_runs_full_sweep(store: KBStore) -> None: assert report.ok is True +def test_lint_surfaces_legacy_uncited_claim_yaml_without_crashing( + store: KBStore, +) -> None: + """Regression for the #82 review: after the Claim.evidence min-citation + validator landed (#81), a KB that already had an uncited claim on + disk from before the fix would crash `vouch lint` / `vouch doctor` + with a bare pydantic.ValidationError. Lint now skips invalid YAMLs + per-file and surfaces them as `invalid_claim` findings so the user + has a clear repair hint (edit the YAML to add a citation, or delete + the file).""" + src = store.put_source(b"e") + store.put_claim(Claim(id="good", text="t", evidence=[src.id])) + + # Hand-craft an uncited claim YAML that the *current* model rejects — + # matches the on-disk shape an older buggy write path could have left. + legacy_uncited = ( + "id: legacy\n" + 'text: "shipped before the validator existed"\n' + "type: fact\n" + "status: stable\n" + "confidence: 1.0\n" + "evidence: []\n" + ) + (store.kb_dir / "claims" / "legacy.yaml").write_text(legacy_uncited) + + report = health.lint(store) + codes = {f.code for f in report.findings} + assert "invalid_claim" in codes, [f.message for f in report.findings] + invalid = next(f for f in report.findings if f.code == "invalid_claim") + assert "legacy" in invalid.object_ids + assert "delete the file" in invalid.message or "add a citation" in invalid.message + assert report.ok is False # invalid_claim is severity=error + + # The good claim is still discoverable — lint didn't bail out at the + # bad one, so the rest of the sweep still ran. + good_findings = [f for f in report.findings if "good" in f.object_ids] + # No errors about the good claim itself (it's well-formed and cites a + # present source). + assert all(f.severity != "error" for f in good_findings), good_findings + + def test_list_claims_filtered_by_status(store: KBStore) -> None: src = store.put_source(b"e") store.put_claim(Claim(id="c1", text="x", evidence=[src.id], From 816d3d0654048149f36bbbedbe788b34fe035843 Mon Sep 17 00:00:00 2001 From: galuis116 Date: Mon, 25 May 2026 16:10:05 -0700 Subject: [PATCH 033/149] health: widen HealthReport.counts type to dict[str, Any] (CI mypy) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The new inline-built counts in lint() (from 5c881de) is a literal dict with mixed value types (str/int/bool), which mypy correctly inferred as dict[str, object] and rejected against the HealthReport.counts: dict[str, int] annotation. The original status() returned the same mixed dict via an untyped 'dict' return, which masked the mismatch — the narrow type was effectively never checked at the call site. Widen counts to dict[str, Any] to match runtime reality. Also tighten status()'s return annotation from 'dict' to 'dict[str, Any]' for consistency. No caller does arithmetic on counts values; they just echo or pass through, so the widening is risk-free. --- src/vouch/health.py | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/src/vouch/health.py b/src/vouch/health.py index 8f571021..11239137 100644 --- a/src/vouch/health.py +++ b/src/vouch/health.py @@ -11,6 +11,7 @@ from dataclasses import dataclass, field from datetime import UTC, datetime, timedelta from pathlib import Path +from typing import Any from pydantic import ValidationError @@ -33,10 +34,15 @@ class Finding: class HealthReport: ok: bool findings: list[Finding] = field(default_factory=list) - counts: dict[str, int] = field(default_factory=dict) + # Mixed value types (str/int/bool) — `claims` etc. are ints, + # `kb_dir` is a str, `index_present` is a bool. Was `dict[str, int]` + # but `status()` already returned the mixed dict via an untyped + # `dict` return annotation; the narrow type was effectively never + # checked. Widened to match runtime reality. + counts: dict[str, Any] = field(default_factory=dict) -def status(store: KBStore) -> dict: +def status(store: KBStore) -> dict[str, Any]: """Quick, machine-readable summary. No deep checks.""" return { "kb_dir": str(store.kb_dir), From cdf72ab5940d366775f7a83fe078bc5db8733fb4 Mon Sep 17 00:00:00 2001 From: galuis116 Date: Mon, 25 May 2026 04:28:13 -0700 Subject: [PATCH 034/149] fix(sessions): close crystallize review-gate bypass via summary page (#76) crystallize wrote a durable Page directly through store.put_page, embedding sess.task, sess.note, and sess.agent verbatim into the rendered markdown body. The body was never gated by propose_page + approve, so an agent calling kb.session_start(task=) and getting any one claim approved via crystallize could land arbitrary content into pages/. The page surfaces in kb.read_page, kb.list_pages, kb.context, and (once #60 is fixed) kb.search. Restrict the summary body to fields the proposing agent cannot influence: session id (server-generated), timestamps (server clock), and the list of approved artifact ids. The agent-controlled fields remain on the Session model itself and are still queryable, but no longer promoted into a durable Page. Also include summary_page_id in the session.crystallize audit event's object_ids when a page is written, so vouch audit truthfully attributes the write. Adds two regression tests: - test_crystallize_summary_page_does_not_leak_agent_controlled_fields - test_crystallize_audit_event_records_summary_page_id --- CHANGELOG.md | 11 ++++++++ src/vouch/sessions.py | 21 ++++++++++----- tests/test_sessions.py | 58 ++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 84 insertions(+), 6 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 92252864..8e7e7d32 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -44,6 +44,17 @@ All notable changes to vouch are documented here. Format follows the same tarball. `import_apply`, `import_check`, and `export_check` now validate every member path and raise on unsafe names. - Fix `vouch search` CLI: assign backend label per code path so substring fallback results are no longer mislabelled as `fts5`; update stale docstring to reflect multi-backend search surface (#52). +- Close the review-gate bypass in `sessions.crystallize` (#76). The + durable session-summary page wrote `sess.task`, `sess.note`, and + `sess.agent` verbatim into rendered markdown, letting an agent + land arbitrary content into `pages/` by calling + `kb.session_start(task=...)` and getting any one claim approved + via crystallize. The summary body now contains only fields the + proposing agent cannot influence (session id, server-clock + timestamps, list of approved artifact ids). The + `session.crystallize` audit event now also includes the summary + page id in `object_ids` when a page is written, so `vouch audit` + truthfully attributes the write. - `vouch crystallize` now indexes its session-summary page into FTS5 so it surfaces from `vouch search` / `kb.search` / `kb.context` without a `vouch index` rebuild. Previously the summary was written via diff --git a/src/vouch/sessions.py b/src/vouch/sessions.py index d27d6334..71fcaa93 100644 --- a/src/vouch/sessions.py +++ b/src/vouch/sessions.py @@ -114,9 +114,12 @@ def crystallize( ) summary_page_id = page.id + crystallize_object_ids = [sess.id, *approved_artifact_ids] + if summary_page_id is not None: + crystallize_object_ids.append(summary_page_id) audit.log_event( store.kb_dir, event="session.crystallize", actor=approver, - object_ids=[sess.id, *approved_artifact_ids], + object_ids=crystallize_object_ids, data={"approved": len(approved_artifact_ids), "failed": len(failures)}, ) return { @@ -128,11 +131,17 @@ def crystallize( def _build_summary_body(sess: Session, ids: list[str]) -> str: - lines = [f"# Session {sess.id}", ""] - if sess.task: - lines += [f"**Task:** {sess.task}", ""] - lines += [ - f"**Agent:** {sess.agent}", + # The summary page is durable and surfaces in kb.read_page / kb.search / + # kb.context, but never goes through propose_page + approve. The body is + # therefore restricted to fields the proposing agent cannot influence — + # session id (server-generated), timestamps (set from server clock at + # session_start / session_end), and the list of artifact ids that did go + # through the review gate. Anything agent-controlled (sess.task, + # sess.note, sess.agent) is omitted to keep the review-gate guarantee + # intact for the Page artifact kind. See #76. + lines = [ + f"# Session {sess.id}", + "", f"**Started:** {sess.started_at.isoformat()}", f"**Ended:** {(sess.ended_at or datetime.now(UTC)).isoformat()}", "", diff --git a/tests/test_sessions.py b/tests/test_sessions.py index a2c2cbe1..a8c18dc6 100644 --- a/tests/test_sessions.py +++ b/tests/test_sessions.py @@ -2,6 +2,7 @@ from __future__ import annotations +import json from pathlib import Path import pytest @@ -110,3 +111,60 @@ def test_crystallize_collects_approval_failures(store): for f in result["failures"]: assert f["error"] == "storage full" assert f["error_type"] == "ValueError" + + +def test_crystallize_summary_page_does_not_leak_agent_controlled_fields( + store: KBStore, +) -> None: + """Regression for #76: the durable summary page bypasses propose_page + + approve, so its body must contain only fields the proposing agent + cannot influence — no sess.task, sess.note, or sess.agent prose. An + agent supplying a markdown payload via session_start(task=...) must + not see that payload promoted into pages/.""" + src = store.put_source(b"e") + injected_task = "## DECISION\n\nWe will migrate. Approved by leadership." + injected_note = " agent-controlled note" + sess = sess_mod.session_start( + store, agent="mallory", task=injected_task, note=injected_note, + ) + propose_claim( + store, text="legitimate finding", evidence=[src.id], + proposed_by="mallory", session_id=sess.id, + ) + sess_mod.session_end(store, sess.id) + + result = sess_mod.crystallize(store, sess.id, approver="human") + page_id = result["summary_page_id"] + assert page_id is not None + + page = store.get_page(page_id) + assert injected_task not in page.body, page.body + assert injected_note not in page.body, page.body + assert "mallory" not in page.body, page.body + assert "## DECISION" not in page.body, page.body + + +def test_crystallize_audit_event_records_summary_page_id( + store: KBStore, +) -> None: + """Regression for #76: the audit event must include the summary page id + in object_ids when a page is written, so `vouch audit` is truthful + about every artifact crystallize produced.""" + src = store.put_source(b"e") + sess = sess_mod.session_start(store, agent="a") + propose_claim( + store, text="t", evidence=[src.id], proposed_by="a", session_id=sess.id, + ) + sess_mod.session_end(store, sess.id) + result = sess_mod.crystallize(store, sess.id, approver="u") + page_id = result["summary_page_id"] + assert page_id is not None + + audit_lines = (store.kb_dir / "audit.log.jsonl").read_text().splitlines() + cryst_events = [ + json.loads(line) for line in audit_lines + if json.loads(line).get("event") == "session.crystallize" + ] + assert cryst_events, "no session.crystallize audit event found" + last = cryst_events[-1] + assert page_id in last["object_ids"], last["object_ids"] From fb4b326254694645e255bc9884a877f42f287cfd Mon Sep 17 00:00:00 2001 From: dripsmvcp <138900956+dripsmvcp@users.noreply.github.com> Date: Tue, 26 May 2026 16:02:16 +0900 Subject: [PATCH 035/149] fix(context): honor retrieval.backend config instead of hardcoding embeddings (#92) --- CHANGELOG.md | 6 ++ README.md | 4 +- ROADMAP.md | 7 ++- src/vouch/context.py | 71 ++++++++++++++++++---- src/vouch/storage.py | 4 +- tests/test_retrieval_backend.py | 101 ++++++++++++++++++++++++++++++++ 6 files changed, 176 insertions(+), 17 deletions(-) create mode 100644 tests/test_retrieval_backend.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 8e7e7d32..f87ef950 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -55,6 +55,12 @@ All notable changes to vouch are documented here. Format follows `session.crystallize` audit event now also includes the summary page id in `object_ids` when a page is written, so `vouch audit` truthfully attributes the write. +- `context._retrieve` now honors `retrieval.backend` in `config.yaml` + instead of always running embeddings first (#92). Accepts `auto` + (default — embedding → FTS5 → substring), `embedding`, `fts5`, or + `substring`; a legacy `retrieval.backends` list is still read for + back-compat. `vouch init` now writes `retrieval.backend: auto`, and the + README/ROADMAP describe the actual behavior. - `vouch crystallize` now indexes its session-summary page into FTS5 so it surfaces from `vouch search` / `kb.search` / `kb.context` without a `vouch index` rebuild. Previously the summary was written via diff --git a/README.md b/README.md index 21bba4c0..14792a41 100644 --- a/README.md +++ b/README.md @@ -261,7 +261,7 @@ vouch import-apply kb.tar.gz --on-conflict skip # apply (default skip; never de | Tool servers | MCP over stdio + JSONL over stdin/stdout, same `kb.*` surface across both transports, capabilities + knowledge-capability descriptor | | Schemas | 13 JSON Schemas (Draft 2020-12) generated from pydantic in [schemas/](schemas/), plus hand-maintained `bundle.manifest` and `jsonl-envelope` schemas | | Write safety | review-gated writes via [proposed/](spec/review-gate.md), `dry_run:true` previews, host trust required for `approve`/`reject`, atomic exclusive-create storage, path-traversal blocked on source intake and bundle import | -| Retrieval | SQLite FTS5 + substring fallback; optional semantic backends (`all-mpnet-base-v2`, `MiniLM-L6`, fastembed-BGE) behind install extras; context packs with citations + quality gate | +| Retrieval | `retrieval.backend` in `config.yaml` selects the path: `auto` (default — embedding → FTS5 → substring), `embedding`, `fts5`, or `substring`. Semantic backends (`all-mpnet-base-v2`, `MiniLM-L6`, fastembed-BGE) ship behind install extras; `auto` degrades to FTS5 when they aren't installed. Context packs with citations + quality gate | | Lifecycle | `supersede`, `contradict`, `archive`, `confirm`, `cite` — direct mutations, all audited | | Portability | tar.gz bundles with per-file sha256 `manifest.json`, `export-check`, `import-check`, `import-apply` with skip/overwrite/fail conflict modes | | Audit | append-only `audit.log.jsonl`, per-event actor (`VOUCH_AGENT`), object ids, dry-run flag, reversible flag | @@ -271,7 +271,7 @@ vouch import-apply kb.tar.gz --on-conflict skip # apply (default skip; never de ## Status -Pre-1.0. What's *not* in this implementation: vector embeddings (BM25/FTS5 only), per-runtime adapter templates, benchmark fixtures, multi-agent sync, scopes beyond a single field on Claim/Source. If a hole matters to you, file an issue. +Pre-1.0. What's *not* in this implementation: per-runtime adapter templates, benchmark fixtures, multi-agent sync, scopes beyond a single field on Claim/Source. If a hole matters to you, file an issue. ## License diff --git a/ROADMAP.md b/ROADMAP.md index 4e5e661f..46101d1f 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -6,8 +6,11 @@ promise. Items marked **[VEP]** require a written proposal in ## 0.1 — surface stabilises (next) -- Vector embeddings as an *optional* retrieval backend alongside FTS5. - Default stays FTS5; embeddings opt-in via `config.yaml`. **[VEP]** +- Vector embeddings as a retrieval backend alongside FTS5 (shipped). + Retrieval is controlled by `retrieval.backend` in `config.yaml`: + `auto` (default) tries embedding → FTS5 → substring, gracefully + degrading to FTS5 when the embeddings extras aren't installed; set + `embedding`, `fts5`, or `substring` to pin a single path. - `vouch diff ` for claim/page revisions. - `vouch approve --batch` for reviewing N proposals in one transaction. - HTTP transport (`vouch serve --transport http`) behind a localhost diff --git a/src/vouch/context.py b/src/vouch/context.py index 667fb518..988dee0f 100644 --- a/src/vouch/context.py +++ b/src/vouch/context.py @@ -16,30 +16,77 @@ import sqlite3 from typing import Any, Literal, cast +import yaml + from . import index_db from .models import ContextItem, ContextPack, ContextQuality from .storage import ArtifactNotFoundError, KBStore ContextItemKind = Literal["claim", "page", "entity", "relation", "source"] +_VALID_BACKENDS = ("auto", "embedding", "fts5", "substring") + + +def _configured_backend(store: KBStore) -> str: + """Resolve the retrieval backend from `config.yaml`, defaulting to "auto". + + Reads the singular `retrieval.backend` string. For KBs initialised + before this knob existed, a legacy `retrieval.backends` list is honoured + by taking its first recognised entry. Anything unreadable or unrecognised + falls back to "auto". + """ + try: + loaded = yaml.safe_load(store.config_path.read_text()) + except (OSError, yaml.YAMLError): + return "auto" + if not isinstance(loaded, dict): + return "auto" + retrieval = loaded.get("retrieval") + if not isinstance(retrieval, dict): + return "auto" + backend = retrieval.get("backend") + if isinstance(backend, str) and backend in _VALID_BACKENDS: + return backend + legacy = retrieval.get("backends") + if isinstance(legacy, list): + for entry in legacy: + if isinstance(entry, str) and entry in _VALID_BACKENDS: + return entry + return "auto" + def _retrieve(store: KBStore, query: str, limit: int ) -> list[tuple[str, str, str, float, str]]: """Return list of (kind, id, summary, score, backend). - Dispatch order: embedding (semantic) -> FTS5 -> substring. + The backend is chosen by `retrieval.backend` in config.yaml: + - "auto" (default): embedding -> FTS5 -> substring + - "embedding": semantic search only + - "fts5": lexical FTS5 only + - "substring": substring scan only """ - raw = index_db.search_semantic(store.kb_dir, query, limit=limit) - if raw: - return [(k, i, s, sc, "embedding") for k, i, s, sc in raw] - try: - hits = index_db.search(store.kb_dir, query, limit=limit) - if hits: - return [(k, i, s, sc, "fts5") for k, i, s, sc in hits] - except sqlite3.Error: - # FTS5 unavailable, db missing, or schema mismatch — fall through - # to substring scan. Other exceptions are real bugs and propagate. - pass + backend = _configured_backend(store) + + if backend in ("auto", "embedding"): + raw = index_db.search_semantic(store.kb_dir, query, limit=limit) + if raw: + return [(k, i, s, sc, "embedding") for k, i, s, sc in raw] + if backend == "embedding": + return [] + + if backend in ("auto", "fts5"): + try: + hits = index_db.search(store.kb_dir, query, limit=limit) + if hits: + return [(k, i, s, sc, "fts5") for k, i, s, sc in hits] + except sqlite3.Error: + # FTS5 unavailable, db missing, or schema mismatch — fall through + # to substring scan (auto) or empty (explicit fts5). Other + # exceptions are real bugs and propagate. + pass + if backend == "fts5": + return [] + return [ (k, i, s, sc, "substring") for k, i, s, sc in store.search_substring(query, limit=limit) diff --git a/src/vouch/storage.py b/src/vouch/storage.py index 6f6a666d..d90c8c10 100644 --- a/src/vouch/storage.py +++ b/src/vouch/storage.py @@ -69,7 +69,9 @@ def _starter_config() -> dict[str, Any]: "version": 1, "review": {"require_human_approval": True}, "retrieval": { - "backends": ["fts5", "substring"], + # auto = embedding -> fts5 -> substring; or pin one of + # embedding | fts5 | substring. See context._retrieve. + "backend": "auto", "default_limit": 10, }, "agents": { diff --git a/tests/test_retrieval_backend.py b/tests/test_retrieval_backend.py new file mode 100644 index 00000000..2dc30cd6 --- /dev/null +++ b/tests/test_retrieval_backend.py @@ -0,0 +1,101 @@ +"""`context._retrieve` honors `retrieval.backend` in config.yaml (#92). + +These tests monkeypatch `index_db.search_semantic` so they exercise the +dispatch logic without needing the optional embeddings extras (numpy / +sentence-transformers), and therefore run under the base CI install. +""" + +from __future__ import annotations + +from pathlib import Path + +import pytest +import yaml + +from vouch import context, health +from vouch.models import Claim +from vouch.storage import KBStore + + +@pytest.fixture +def store(tmp_path: Path) -> KBStore: + s = KBStore.init(tmp_path) + src = s.put_source(b"e") + s.put_claim(Claim(id="c1", text="JWT token rotation", evidence=[src.id])) + health.rebuild_index(s) + return s + + +def _set_backend(store: KBStore, backend: str) -> None: + cfg = yaml.safe_load(store.config_path.read_text()) + cfg.setdefault("retrieval", {})["backend"] = backend + store.config_path.write_text(yaml.safe_dump(cfg)) + + +def _force_semantic_hit(monkeypatch: pytest.MonkeyPatch) -> None: + """Make the embedding path always return a hit, so a backend label of + "embedding" appears iff `_retrieve` actually consulted semantic search.""" + monkeypatch.setattr( + context.index_db, "search_semantic", + lambda *a, **k: [("claim", "c1", "JWT token rotation", 0.99)], + ) + + +def _backends(pack: dict) -> set[str]: + return {item["backend"] for item in pack["items"]} + + +def test_backend_fts5_skips_embedding( + store: KBStore, monkeypatch: pytest.MonkeyPatch +) -> None: + """Regression for #92: with retrieval.backend=fts5, the embedding path + must not run even when it would return hits.""" + _force_semantic_hit(monkeypatch) + _set_backend(store, "fts5") + pack = context.build_context_pack(store, query="JWT") + assert pack["items"] + assert "embedding" not in _backends(pack) + assert _backends(pack) <= {"fts5", "substring"} + + +def test_backend_embedding_is_recognized( + store: KBStore, monkeypatch: pytest.MonkeyPatch +) -> None: + """`embedding` is an accepted value and forces the semantic path.""" + _force_semantic_hit(monkeypatch) + _set_backend(store, "embedding") + pack = context.build_context_pack(store, query="JWT") + assert pack["items"] + assert _backends(pack) == {"embedding"} + + +def test_backend_substring_only( + store: KBStore, monkeypatch: pytest.MonkeyPatch +) -> None: + _force_semantic_hit(monkeypatch) + _set_backend(store, "substring") + pack = context.build_context_pack(store, query="JWT") + assert pack["items"] + assert _backends(pack) == {"substring"} + + +def test_backend_auto_prefers_embedding( + store: KBStore, monkeypatch: pytest.MonkeyPatch +) -> None: + """Default `auto` tries embedding first when it returns hits.""" + _force_semantic_hit(monkeypatch) + _set_backend(store, "auto") + pack = context.build_context_pack(store, query="JWT") + assert any(item["backend"] == "embedding" for item in pack["items"]) + + +def test_unset_backend_defaults_to_auto( + store: KBStore, monkeypatch: pytest.MonkeyPatch +) -> None: + """A config with no retrieval.backend behaves like `auto`.""" + _force_semantic_hit(monkeypatch) + cfg = yaml.safe_load(store.config_path.read_text()) + cfg.get("retrieval", {}).pop("backend", None) + store.config_path.write_text(yaml.safe_dump(cfg)) + pack = context.build_context_pack(store, query="JWT") + assert any(item["backend"] == "embedding" for item in pack["items"]) From e7df0475328cdecf4918983cec6c127e4553b7e3 Mon Sep 17 00:00:00 2001 From: dripsmvcp <138900956+dripsmvcp@users.noreply.github.com> Date: Tue, 26 May 2026 16:11:34 +0900 Subject: [PATCH 036/149] feat(cli): vouch approve accepts multiple ids for scriptable bulk approval (#93) --- CHANGELOG.md | 8 +++++ src/vouch/cli.py | 59 +++++++++++++++++++++++++++++++---- src/vouch/proposals.py | 61 ++++++++++++++++++++++++++---------- tests/test_cli.py | 71 ++++++++++++++++++++++++++++++++++++++++++ 4 files changed, 177 insertions(+), 22 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f87ef950..88e2e874 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -22,6 +22,14 @@ All notable changes to vouch are documented here. Format follows (`rebuild_index`, `doctor`, bundle `export`/`import_apply`) surfaced as status lines on interactive terminals; and `vouch index` / `vouch export` now report a clean `Error:` instead of a traceback on a malformed artifact. +- `vouch approve …` approves multiple proposals in one + non-interactive call for CI and backlog clearing (#93). Default is + all-or-nothing: every id is validated as an approvable pending proposal + before any is written, so a typo or already-decided id aborts the batch + without approving anything. `--keep-going` switches to best-effort + (approve what you can, report the rest, exit non-zero on partial failure). + One audit event is still recorded per approved artifact. Complements the + interactive `vouch review` queue. - `vouch sync-check` and `vouch sync-apply` reconcile another `.vouch` directory or bundle by importing only non-conflicting durable artifacts and reporting conflicts without overwriting reviewed knowledge. diff --git a/src/vouch/cli.py b/src/vouch/cli.py index 2944d4b4..d5b79c3e 100644 --- a/src/vouch/cli.py +++ b/src/vouch/cli.py @@ -33,6 +33,7 @@ from .onboarding import seed_starter_kb from .proposals import ( ProposalError, + check_approvable, propose_claim, propose_entity, propose_page, @@ -395,14 +396,60 @@ def show(proposal_id: str) -> None: @cli.command() -@click.argument("proposal_id") +@click.argument("proposal_ids", nargs=-1, required=True) @click.option("--reason", default=None) -def approve(proposal_id: str, reason: str | None) -> None: - """Approve a proposal — converts it into a durable artifact.""" +@click.option( + "--keep-going", is_flag=True, + help="Best-effort: approve every id that can be approved and report the " + "rest, instead of the default all-or-nothing precheck.", +) +def approve(proposal_ids: tuple[str, ...], reason: str | None, keep_going: bool) -> None: + """Approve one or more proposals — converts each into a durable artifact. + + Pass several ids to approve a batch in one call (useful for CI and + clearing a review backlog). One audit event is recorded per approved + artifact. + + Semantics: + + \b + - default (all-or-nothing): every id is validated as an approvable + pending proposal before any is written; a typo or already-decided id + aborts the whole batch and nothing is approved. + - --keep-going (best-effort): approve each id independently, report the + failures, and exit non-zero if any failed. + """ store = _load_store() - with _cli_errors(): - artifact = do_approve(store, proposal_id, approved_by=_whoami(), reason=reason) - click.echo(f"Approved → {type(artifact).__name__.lower()}/{artifact.id}") + approver = _whoami() + + if not keep_going: + blocked = [ + (pid, reason_blocked) + for pid in proposal_ids + if (reason_blocked := check_approvable(store, pid, approved_by=approver)) + ] + if blocked: + for pid, why in blocked: + click.echo(f"✗ {pid}: {why}", err=True) + raise click.ClickException( + f"refusing to approve: {len(blocked)} of {len(proposal_ids)} not " + "approvable — nothing was approved (use --keep-going for best-effort)" + ) + + failures = 0 + for pid in proposal_ids: + try: + artifact = do_approve(store, pid, approved_by=approver, reason=reason) + except (ArtifactNotFoundError, ValueError, ProposalError, LifecycleError) as e: + failures += 1 + click.echo(f"✗ {pid}: {e}", err=True) + continue + click.echo(f"Approved → {type(artifact).__name__.lower()}/{artifact.id}") + + if failures: + raise click.ClickException( + f"{failures} of {len(proposal_ids)} proposal(s) failed to approve" + ) @cli.command() diff --git a/src/vouch/proposals.py b/src/vouch/proposals.py index dbca9260..47592e71 100644 --- a/src/vouch/proposals.py +++ b/src/vouch/proposals.py @@ -210,23 +210,17 @@ def propose_relation( # --- decisions ------------------------------------------------------------ -def approve( - store: KBStore, - proposal_id: str, - *, - approved_by: str, - reason: str | None = None, -) -> Claim | Page | Entity | Relation: - """Approve a pending proposal and write it as a durable artifact. - - Raises ProposalError if the proposal is not pending or if - approved_by matches proposed_by (forbidden_self_approval). +def _approval_block_reason( + store: KBStore, proposal: Proposal, approved_by: str +) -> str | None: + """Why `approved_by` cannot approve `proposal` right now, or None. + + Covers the deterministic pre-write gates — not-pending and + forbidden_self_approval. Shared by `approve()` and `check_approvable()` + so the single-approve path and the batch CLI's precheck never drift. """ - proposal = store.get_proposal(proposal_id) if proposal.status != ProposalStatus.PENDING: - raise ProposalError( - f"proposal {proposal_id} is {proposal.status.value}, not pending" - ) + return f"proposal {proposal.id} is {proposal.status.value}, not pending" if approved_by == proposal.proposed_by: cfg: dict[str, Any] = {} try: @@ -241,10 +235,45 @@ def approve( review_cfg.get("approver_role") if isinstance(review_cfg, dict) else None ) if approver_role != "trusted-agent": - raise ProposalError( + return ( f"forbidden_self_approval: {approved_by} cannot approve their own " "proposal (set review.approver_role: trusted-agent in config.yaml to opt out)" ) + return None + + +def check_approvable( + store: KBStore, proposal_id: str, *, approved_by: str +) -> str | None: + """Return why `proposal_id` can't be approved by `approved_by`, or None. + + Read-only. `None` means the deterministic gates pass; the actual write in + `approve()` can still fail on a pre-existing artifact or an I/O error. + Used by the batch CLI to validate a whole set before mutating anything. + """ + try: + proposal = store.get_proposal(proposal_id) + except ArtifactNotFoundError: + return f"proposal {proposal_id} not found" + return _approval_block_reason(store, proposal, approved_by) + + +def approve( + store: KBStore, + proposal_id: str, + *, + approved_by: str, + reason: str | None = None, +) -> Claim | Page | Entity | Relation: + """Approve a pending proposal and write it as a durable artifact. + + Raises ProposalError if the proposal is not pending or if + approved_by matches proposed_by (forbidden_self_approval). + """ + proposal = store.get_proposal(proposal_id) + block = _approval_block_reason(store, proposal, approved_by) + if block: + raise ProposalError(block) payload = dict(proposal.payload) # Refuse to overwrite an existing artifact. Without this guard a retry # after a crash between put_() and move_proposal_to_decided() would diff --git a/tests/test_cli.py b/tests/test_cli.py index 9a11255a..4a2f7af4 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -316,3 +316,74 @@ def _side_effect(store, proposal_id, **kwargs): assert result.exit_code == 0 assert "warning:" in result.stderr assert "1/2 proposal(s) failed" in result.stderr + + +# --- batch approval (#93) ------------------------------------------------- + + +def _propose_n(store: KBStore, n: int) -> list[str]: + src = store.put_source(b"e") + ids = [] + for i in range(n): + pr = propose_claim( + store, text=f"batch claim {i}", evidence=[src.id], proposed_by="agent" + ) + ids.append(pr.id) + return ids + + +def test_approve_batch_approves_all( + store: KBStore, monkeypatch: pytest.MonkeyPatch +) -> None: + monkeypatch.delenv("VOUCH_AGENT", raising=False) + ids = _propose_n(store, 3) + result = CliRunner().invoke(cli, ["approve", *ids]) + assert result.exit_code == 0, result.output + pending = {p.id for p in store.list_proposals(ProposalStatus.PENDING)} + for pid in ids: + assert pid not in pending + assert result.output.count("Approved") == 3 + + +def test_approve_batch_one_audit_event_per_artifact( + store: KBStore, monkeypatch: pytest.MonkeyPatch +) -> None: + from vouch import audit + monkeypatch.delenv("VOUCH_AGENT", raising=False) + ids = _propose_n(store, 2) + CliRunner().invoke(cli, ["approve", *ids]) + approve_events = [ + e for e in audit.read_events(store.kb_dir) + if e.event.endswith(".approve") + ] + assert len(approve_events) == 2 + + +def test_approve_batch_atomic_aborts_on_bad_id( + store: KBStore, monkeypatch: pytest.MonkeyPatch +) -> None: + """Default is all-or-nothing: one bad id approves nothing.""" + monkeypatch.delenv("VOUCH_AGENT", raising=False) + good = _propose_n(store, 2) + result = CliRunner().invoke(cli, ["approve", good[0], "no-such-id", good[1]]) + assert result.exit_code != 0, result.output + assert "Traceback" not in result.output + # Nothing approved — both good proposals are still pending. + for cid in good: + assert cid in {p.id for p in store.list_proposals(ProposalStatus.PENDING)} + + +def test_approve_batch_keep_going_best_effort( + store: KBStore, monkeypatch: pytest.MonkeyPatch +) -> None: + """--keep-going approves what it can and exits non-zero on partial failure.""" + monkeypatch.delenv("VOUCH_AGENT", raising=False) + good = _propose_n(store, 2) + result = CliRunner().invoke( + cli, ["approve", "--keep-going", good[0], "no-such-id", good[1]] + ) + assert result.exit_code != 0, result.output + # Both valid proposals were approved despite the bad id in the middle. + pending = {p.id for p in store.list_proposals(ProposalStatus.PENDING)} + assert good[0] not in pending + assert good[1] not in pending From 7fd2fed8efb2f99b5fa584a49782c9a1bc92d5ef Mon Sep 17 00:00:00 2001 From: jsdevninja Date: Tue, 26 May 2026 12:21:58 -0500 Subject: [PATCH 037/149] feat: add batch approval for reviewed proposals --- README.md | 3 ++- docs/review-gate.md | 15 +++++++++++---- 2 files changed, 13 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index 14792a41..034fa190 100644 --- a/README.md +++ b/README.md @@ -62,6 +62,7 @@ vouch status # one-line summary vouch pending # list pending proposals vouch show # full details vouch approve # → durable artifact +vouch approve ... # approve several reviewed proposals at once vouch reject --reason "..." # 4. commit @@ -147,7 +148,7 @@ vouch doctor # full sweep incl. source verificati vouch pending # list pending proposals vouch review [--limit N] [--type KIND] # guided proposal review queue vouch show -vouch approve [--reason ...] +vouch approve ... [--reason ...] [--keep-going] vouch reject --reason "..." vouch propose-claim --text ... --source ... [--type ...] [--confidence X] diff --git a/docs/review-gate.md b/docs/review-gate.md index 9aff9bb0..fa6e37b4 100644 --- a/docs/review-gate.md +++ b/docs/review-gate.md @@ -40,6 +40,7 @@ vouch status # snapshot vouch pending # what's waiting vouch show # full proposal details vouch approve # → durable artifact + decided/ +vouch approve # approve several reviewed proposals at once vouch reject --reason "duplicates auth-uses-jwt" ``` @@ -151,10 +152,16 @@ next review; you can remove the entries with `git rm` afterwards. Update your local `.gitignore` so it stops happening. **Q: Can I approve in bulk?** -Today, one at a time. `--batch` is planned for 0.1 -([ROADMAP](../ROADMAP.md)). Until then, -`vouch pending --json | jq -r '.[].id' | xargs -n1 vouch approve` is -the workaround — read what you're approving first. +Yes. Review the proposals first, then pass the reviewed ids: + +```bash +vouch approve --reason "reviewed together" +``` + +By default, every id is validated before any are written, so a typo or +already-decided id aborts the batch without approving anything. Add +`--keep-going` for best-effort approval (approve what you can, report the +rest, exit non-zero on partial failure). **Q: What if I want a *softer* gate — "warn, don't block"?** You don't. If you want soft, use a tool without a gate. vouch's From 972e2d72cfd6bd5d041a3fc6fe232c6700270167 Mon Sep 17 00:00:00 2001 From: jsdevninja Date: Thu, 28 May 2026 00:54:10 -0500 Subject: [PATCH 038/149] fix: simplify doc --- docs/review-gate.md | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/docs/review-gate.md b/docs/review-gate.md index fa6e37b4..29e75a2a 100644 --- a/docs/review-gate.md +++ b/docs/review-gate.md @@ -152,16 +152,14 @@ next review; you can remove the entries with `git rm` afterwards. Update your local `.gitignore` so it stops happening. **Q: Can I approve in bulk?** -Yes. Review the proposals first, then pass the reviewed ids: +Yes. Review proposals, then approve them together: ```bash vouch approve --reason "reviewed together" ``` -By default, every id is validated before any are written, so a typo or -already-decided id aborts the batch without approving anything. Add -`--keep-going` for best-effort approval (approve what you can, report the -rest, exit non-zero on partial failure). +Default is all-or-nothing: if any id is invalid or already decided, +nothing is approved. Use `--keep-going` for best-effort approval. **Q: What if I want a *softer* gate — "warn, don't block"?** You don't. If you want soft, use a tool without a gate. vouch's From 4b5b32537e01844fb430ae2c64c781abaf83f47f Mon Sep 17 00:00:00 2001 From: plind-junior <59729252+plind-junior@users.noreply.github.com> Date: Fri, 29 May 2026 01:44:25 +0900 Subject: [PATCH 039/149] docs: spec for vouch init --template domain starter packs generalize init seeding into a named-template registry and ship a gittensor (SN74) starter pack as the first non-default template. complements gittensory rather than cloning its live-data signals. --- .../specs/2026-05-27-init-templates-design.md | 100 ++++++++++++++++++ 1 file changed, 100 insertions(+) create mode 100644 docs/superpowers/specs/2026-05-27-init-templates-design.md diff --git a/docs/superpowers/specs/2026-05-27-init-templates-design.md b/docs/superpowers/specs/2026-05-27-init-templates-design.md new file mode 100644 index 00000000..ac67a49d --- /dev/null +++ b/docs/superpowers/specs/2026-05-27-init-templates-design.md @@ -0,0 +1,100 @@ +# vouch init --template — domain starter packs + +## Problem + +A fresh `.vouch/` KB starts nearly empty (one generic starter claim). When you +spin up vouch inside a domain-specific repo — e.g. Gittensor (SN74) — it knows +nothing about that domain, so the first agent session has no cited context to +retrieve. We want `vouch init` to optionally seed a domain-aware starter pack. + +## Goal + +Generalize `vouch init`'s seeding into a named-template registry and ship a +`gittensor` pack as the first non-default template: + +``` +vouch init [--path P] [--template starter|gittensor] +``` + +Default stays the current `starter` seed (behavior unchanged). `gittensor` +seeds a small, cited, **already-approved** knowledge base about how SN74 +contribution scoring works. + +## Decisions + +- **Registry, not a one-off flag.** A `TEMPLATES` dict maps name → seed + function so future packs (research-notes, codebase-audit) plug in the same + way. +- **Approved-direct seeding.** Like the existing `seed_starter_kb`, template + artifacts are written approved at init time — this is bootstrap content, not + the review queue. +- **Idempotent.** Stable ids / content hashes mean re-running `init` creates + nothing new. +- **In-code templates, not files.** No `templates//` on disk and no + reliance on `vouch ingest`; the pack is a seed function. + +## Components — `src/vouch/onboarding.py` + +### `SeedResult` (dataclass) +`template: str`, `created: list[str]` (artifact ids actually created), +`created_anything: bool`. Replaces/absorbs the current `StarterSeedResult`. + +### `TEMPLATES` registry +```python +TEMPLATES: dict[str, Callable[[KBStore, str], SeedResult]] = { + "starter": seed_starter_kb, + "gittensor": seed_gittensor_kb, +} +``` +`available_templates() -> list[str]` returns sorted keys (for help + errors). + +### `seed_starter_kb(store, approved_by) -> SeedResult` +The current default seed, refactored to return `SeedResult`. Behavior unchanged +(same source + claim ids). + +### `seed_gittensor_kb(store, approved_by) -> SeedResult` +Seeds, idempotently and approved-direct: +- **1 Source** — SN74 description text (`title="Gittensor SN74"`, + `locator="vouch:template/gittensor"`, `source_type="message"`, + `media_type="text/markdown"`), content from Gittensor's public README facts. +- **1 Entity** — `gittensor-sn74` (`type=project`). +- **4 Claims** (`type=fact`, `status=stable`, each citing the source, linked to + the `gittensor-sn74` entity): + 1. Miners earn TAO for pull requests merged into whitelisted repositories. + 2. Validators verify GitHub account ownership via a fine-grained PAT before scoring. + 3. Contributions are scored by code quality, repository allocation, and language factors. + 4. Sybil-resistant: GitHub account verification + merged-PR requirement prevent gaming. +- Each artifact keyed by a stable id; existence checks make re-runs no-ops. + +## CLI — `src/vouch/cli.py` + +`init` gains `--template` (default `"starter"`): +- Resolve the name against `TEMPLATES`; unknown → `_cli_errors`-style + `Error: unknown template '' (available: gittensor, starter)`. +- Call the selected seed; print a generalized summary + (`Seeded