Skip to content
45 changes: 45 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <id1> <id2> …` 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.
Expand All @@ -44,6 +52,23 @@ 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.
- `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
Expand All @@ -68,6 +93,26 @@ 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. **Migration note:** because
the validator also fires when claims are read back, a KB that
already has an uncited `claims/<id>.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

Expand Down
7 changes: 4 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,7 @@ vouch status # one-line summary
vouch pending # list pending proposals
vouch show <id> # full details
vouch approve <id> # → durable artifact
vouch approve <id> <id> ... # approve several reviewed proposals at once
vouch reject <id> --reason "..."

# 4. commit
Expand Down Expand Up @@ -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 <proposal-id>
vouch approve <proposal-id> [--reason ...]
vouch approve <proposal-id>... [--reason ...] [--keep-going]
vouch reject <proposal-id> --reason "..."

vouch propose-claim --text ... --source ... [--type ...] [--confidence X]
Expand Down Expand Up @@ -261,7 +262,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 |
Expand All @@ -271,7 +272,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

Expand Down
7 changes: 5 additions & 2 deletions ROADMAP.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <id-old> <id-new>` for claim/page revisions.
- `vouch approve --batch` for reviewing N proposals in one transaction.
- HTTP transport (`vouch serve --transport http`) behind a localhost
Expand Down
13 changes: 9 additions & 4 deletions docs/review-gate.md
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@ vouch status # snapshot
vouch pending # what's waiting
vouch show <id> # full proposal details
vouch approve <id> # → durable artifact + decided/
vouch approve <id> <id> # approve several reviewed proposals at once
vouch reject <id> --reason "duplicates auth-uses-jwt"
```

Expand Down Expand Up @@ -151,10 +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?**
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 proposals, then approve them together:

```bash
vouch approve <id> <id> --reason "reviewed together"
```

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
Expand Down
59 changes: 53 additions & 6 deletions src/vouch/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@
from .onboarding import seed_starter_kb
from .proposals import (
ProposalError,
check_approvable,
propose_claim,
propose_entity,
propose_page,
Expand Down Expand Up @@ -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()
Expand Down
71 changes: 59 additions & 12 deletions src/vouch/context.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
Loading
Loading