Skip to content
9 changes: 9 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,15 @@ All notable changes to vouch are documented here. Format follows
## [1.3.0] — 2026-07-14

### Added
- typed `Config` model for `.vouch/config.yaml`: the file is parsed once into a
validated pydantic `Config` (with nested `ReviewConfig` / `RetrievalConfig`)
exposed as `KBStore.config`, replacing the per-call-site untyped `dict` reads
in `proposals.py` and the retrieval backend selector. a malformed value
(e.g. `retrieval.default_limit: "ten"`) now fails fast with the offending key
path instead of silently falling back, and `vouch doctor` surfaces unknown
top-level keys as likely typos instead of dropping them. existing KBs with
partial or absent `retrieval:` / `review:` blocks load with the documented
defaults — no on-disk change (#243).
Comment on lines +61 to +69

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

document the complete configuration-diagnostics behavior

the entry omits two user-visible behaviors added in this stack: malformed yaml syntax is converted into ConfigError for vouch doctor, and nested unknown keys are reported with dotted paths. please mention both so the changelog accurately reflects the shipped diagnostics.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@CHANGELOG.md` around lines 28 - 36, Update the changelog entry describing
configuration validation and diagnostics to also mention that malformed YAML
syntax is converted into ConfigError for vouch doctor, and that unknown nested
configuration keys are reported using dotted paths. Preserve the existing
details about validation, defaults, and top-level unknown-key reporting.

- `vouch console`: serve the vendored React web console straight from the
installed package — a same-origin `/proxy` bridge (loopback-guarded) to
`vouch serve --transport http` backends, reimplementing the vite dev-proxy
Expand Down
23 changes: 3 additions & 20 deletions src/vouch/context.py
Original file line number Diff line number Diff line change
Expand Up @@ -53,27 +53,10 @@ def _configured_backend(store: KBStore) -> str:

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".
by taking its first recognised entry. Anything unrecognised falls back to
"auto". Parsing + validation now lives in the typed `Config` model (#243).
"""
try:
loaded = yaml.safe_load(store.config_path.read_text(encoding="utf-8"))
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"
return store.config.retrieval.resolved_backend()
Comment on lines +56 to +59

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟠 Major | 🏗️ Heavy lift

migrate _configured_rerank to the typed config model.

while _configured_backend was updated to use store.config, the adjacent _configured_rerank function (lines 61-90) still manually reads and parses config.yaml using yaml.safe_load. this contradicts the pr objective to replace untyped yaml parsing and update existing configuration readers.

additionally, if rerank was not added to the new RetrievalConfig model, doctor() will incorrectly flag retrieval.rerank as a warning for an unknown key. please update _configured_rerank to consume store.config.retrieval.rerank (adding it to the model if missing) to eliminate redundant i/o and enforce the new schema.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/vouch/context.py` around lines 55 - 58, The `_configured_rerank` function
still parses config.yaml directly instead of using the typed configuration.
Replace its YAML loading and manual parsing with
`store.config.retrieval.rerank`, and add the `rerank` field to `RetrievalConfig`
if it is missing so the key is schema-recognized and doctor() does not warn
about it.



def _configured_rerank(store: KBStore, *, limit: int) -> tuple[bool, int]:
Expand Down
26 changes: 24 additions & 2 deletions src/vouch/health.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,16 @@

from . import index_db
from .audit import count_events, verify_chain
from .models import Claim, ClaimStatus, Entity, Page, ProposalKind, ProposalStatus, Source
from .models import (
Claim,
ClaimStatus,
ConfigError,
Entity,
Page,
ProposalKind,
ProposalStatus,
Source,
)
from .storage import KBStore, _yaml_load, sha256_hex
from .verify import verify_all

Expand Down Expand Up @@ -284,7 +293,9 @@ def doctor(store: KBStore) -> HealthReport:
)
)

# Config sanity.
# Config sanity — a missing file is an error; otherwise a malformed value
# is an error and an unknown key is a likely typo (silently ignored before
# #243, now surfaced).
if not store.config_path.exists():
report.findings.append(
Finding(
Expand All @@ -293,6 +304,17 @@ def doctor(store: KBStore) -> HealthReport:
"config.yaml is missing",
)
)
else:
try:
cfg = store.config
except ConfigError as e:
report.findings.append(Finding("error", "config_invalid", str(e)))
else:
for key in cfg.unknown_keys():
report.findings.append(Finding(
"warning", "config_unknown_key",
f"unknown config key {key!r} — possible typo, ignored",
))

# Index presence (warning only — the index is derivable).
if not (store.kb_dir / index_db.DB_FILENAME).exists():
Expand Down
126 changes: 125 additions & 1 deletion src/vouch/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@
from enum import StrEnum
from typing import Any, Literal

from pydantic import BaseModel, Field, field_validator
from pydantic import BaseModel, ConfigDict, Field, ValidationError, field_validator


def utcnow() -> datetime:
Expand Down Expand Up @@ -524,3 +524,127 @@ class Capabilities(BaseModel):
default_factory=dict,
description="Hot-memory sidebar contract on read-side kb.* responses",
)


# --- config.yaml (issue #243) ---------------------------------------------
#
# `.vouch/config.yaml` used to be read as an untyped dict at each call site,
# with nested `.get()` guards and silent `except: {}` fallbacks. That spread
# the schema across the codebase and swallowed typos. These models are the
# single source of truth for a valid config; `KBStore.config` parses the file
# into a `Config` once, and malformed values fail fast with a per-field path.


class ConfigError(ValueError):
"""`.vouch/config.yaml` could not be parsed into a valid Config."""


class ReviewConfig(BaseModel):
"""`review:` block — the write-gate policy."""

model_config = ConfigDict(extra="allow")

require_human_approval: bool = True
expire_pending_after_days: int = Field(default=90, ge=0)
# "trusted-agent" lets an agent approve its own proposals; anything else
# (incl. unset) keeps the human-in-the-loop gate.
approver_role: str | None = None
# Phase D self-approval opt-in: a self-proposed CLAIM whose byte-offset
# receipts all verify is auto-approved without a human. Off by default —
# the human-review gate stays on.
auto_approve_on_receipt: bool = False


class RetrievalConfig(BaseModel):
"""`retrieval:` block — how kb.search / kb.context pick a backend."""

model_config = ConfigDict(extra="allow")

# Unset (None) means "not pinned" — resolution then consults the legacy
# list and finally defaults to "auto". The starter config sets "auto"
# explicitly. Keeping this optional is what lets a legacy `backends`-only
# KB still resolve via the list.
backend: str | None = None
default_limit: int = Field(default=10, ge=1)
# Legacy plural list, honoured for KBs created before `backend` existed.
backends: list[str] | None = None
# Optional retrieval stages owned by their own readers in `context.py`
# (`_configured_rerank` / `_configured_recency`). Declared here so a real
# `retrieval.rerank` / `retrieval.recency` block is not flagged as a typo.
rerank: dict[str, Any] | None = None
recency: dict[str, Any] | None = None

def resolved_backend(self) -> str:
"""Effective backend, preserving pre-#243 fallback behaviour.

An explicit, recognised `backend` wins; else a legacy `backends` list
contributes its first recognised entry; else "auto". Unrecognised
values fall back to "auto". See `context._retrieve`.
"""
valid = ("auto", "hybrid", "embedding", "fts5", "substring")
if self.backend is not None and self.backend in valid:
return self.backend
for entry in self.backends or []:
if entry in valid:
return entry
return "auto"


class Config(BaseModel):
"""Typed view of `.vouch/config.yaml`, parsed once at store-open.

Known top-level sections are validated; unknown ones are preserved (not
dropped) so `vouch doctor` can flag them as likely typos — see
`unknown_keys()`. Sections owned by their own readers (`serve`,
`volunteer`, `mcp`, `capture`, `recall`, `compile`, `triage`) are kept
loose here so they neither break nor trip the unknown-key check.
"""

model_config = ConfigDict(extra="allow")

version: int = 1
review: ReviewConfig = Field(default_factory=ReviewConfig)
retrieval: RetrievalConfig = Field(default_factory=RetrievalConfig)
agents: dict[str, Any] = Field(default_factory=dict)
page_kinds: dict[str, Any] = Field(default_factory=dict)
serve: dict[str, Any] | None = None
volunteer: dict[str, Any] | None = None
mcp: dict[str, Any] | None = None
capture: dict[str, Any] | None = None
recall: dict[str, Any] | None = None
compile: dict[str, Any] | None = None
triage: dict[str, Any] | None = None

@classmethod
def load(cls, raw: Any) -> Config:
"""Parse a `yaml.safe_load` result into a Config.

`None` (empty file) and `{}` both yield all-defaults. A non-mapping
top level, or a per-field type error, raises `ConfigError` naming the
offending path.
"""
if raw is None:
raw = {}
if not isinstance(raw, dict):
raise ConfigError(
f"config.yaml must be a mapping at the top level, "
f"got {type(raw).__name__}"
)
try:
return cls.model_validate(raw)
except ValidationError as e:
first = e.errors()[0]
loc = ".".join(str(p) for p in first["loc"]) or "<root>"
raise ConfigError(f"config.yaml: {loc}: {first['msg']}") from e

def unknown_keys(self) -> list[str]:
"""Keys outside the known schema (likely typos), dotted for nesting.

Walks the top level plus the two validated nested blocks, so a typo
one level deep (`review.expier_pending_after_days`) is surfaced the
same way a top-level one is, rather than silently swallowed.
"""
keys = set(self.__pydantic_extra__ or {})
keys |= {f"review.{k}" for k in (self.review.__pydantic_extra__ or {})}
keys |= {f"retrieval.{k}" for k in (self.retrieval.__pydantic_extra__ or {})}
return sorted(keys)
36 changes: 4 additions & 32 deletions src/vouch/proposals.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,6 @@
from datetime import UTC, datetime, timedelta
from typing import Any

import yaml
from pydantic import ValidationError

from . import audit, index_db
Expand All @@ -36,7 +35,6 @@ class ProposalError(RuntimeError):

EXPIRE_REASON = "expired"
EXPIRE_ACTOR = "vouch-expire"
_DEFAULT_EXPIRE_PENDING_DAYS = 90


@dataclass
Expand Down Expand Up @@ -411,19 +409,6 @@ def propose_delete(
# --- decisions ------------------------------------------------------------


def _review_config(store: KBStore) -> dict[str, Any]:
"""The ``review:`` section of config.yaml, or {} if absent/unreadable."""
try:
loaded = yaml.safe_load(
(store.kb_dir / "config.yaml").read_text(encoding="utf-8")
)
except Exception:
return {}
if isinstance(loaded, dict) and isinstance(loaded.get("review"), dict):
return loaded["review"]
return {}


def _claim_receipts_verify(store: KBStore, proposal: Proposal) -> bool:
"""True if this CLAIM proposal's citations all carry receipts that verify."""
from . import receipts
Expand Down Expand Up @@ -455,17 +440,16 @@ def _approval_block_reason(
f"forbidden_self_approval: page kind '{page_type}' is protected — "
"it always requires a reviewer other than the proposer"
)
review_cfg = _review_config(store)
# Blanket opt-out: trust the agent for everything.
if review_cfg.get("approver_role") == "trusted-agent":
if store.config.review.approver_role == "trusted-agent":
return None
# Phase D: the receipt is the reviewer. A claim whose byte-offset
# receipts all verify needs no human — the mechanical check already
# confirmed the quoted span is in the source. A claim that cannot quote
# its source (bare source id, forged or missing receipt) does not
# qualify and still falls through to the human gate below.
if (
review_cfg.get("auto_approve_on_receipt")
store.config.review.auto_approve_on_receipt
and proposal.kind == ProposalKind.CLAIM
and _claim_receipts_verify(store, proposal)
):
Expand All @@ -490,7 +474,7 @@ def auto_approve_receipts(
just captures knowledge" real. No-op unless ``review.auto_approve_on_receipt``
is set, so the human-review gate is never silently bypassed.
"""
if not _review_config(store).get("auto_approve_on_receipt"):
if not store.config.review.auto_approve_on_receipt:
return []
approved: list[Claim] = []
for proposal in store.list_proposals(ProposalStatus.PENDING):
Expand Down Expand Up @@ -763,19 +747,7 @@ def expire_pending_after_days(store: KBStore, *, override: int | None = None) ->
"""Resolve GC threshold from config (`review.expire_pending_after_days`)."""
if override is not None:
return override
try:
loaded = yaml.safe_load(store.config_path.read_text(encoding="utf-8"))
except Exception:
return _DEFAULT_EXPIRE_PENDING_DAYS
if not isinstance(loaded, dict):
return _DEFAULT_EXPIRE_PENDING_DAYS
review_cfg = loaded.get("review")
if not isinstance(review_cfg, dict):
return _DEFAULT_EXPIRE_PENDING_DAYS
days = review_cfg.get("expire_pending_after_days")
if isinstance(days, int) and days >= 0:
return days
return _DEFAULT_EXPIRE_PENDING_DAYS
return store.config.review.expire_pending_after_days


def _utc(dt: datetime) -> datetime:
Expand Down
24 changes: 24 additions & 0 deletions src/vouch/storage.py
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@
import re
import sqlite3
import stat
from functools import cached_property
from pathlib import Path
from typing import Any, TypeVar

Expand All @@ -37,6 +38,8 @@

from .models import (
Claim,
Config,
ConfigError,
Entity,
Evidence,
Page,
Expand Down Expand Up @@ -317,6 +320,27 @@ def init(cls, root: Path) -> KBStore:
def config_path(self) -> Path:
return self.kb_dir / CONFIG_FILENAME

@cached_property
def config(self) -> Config:
"""Typed, validated view of config.yaml, parsed once. (#243)

A missing file yields all-defaults (a KB may predate `config.yaml`);
a malformed file raises `ConfigError` naming the offending key.
Cached per store instance — construct a fresh `KBStore` to re-read
after rewriting config.
"""
try:
text = self.config_path.read_text(encoding="utf-8")
except FileNotFoundError:
return Config()
except OSError as e:
raise ConfigError(f"cannot read {self.config_path}: {e}") from e
try:
raw = _yaml_load(text)
except yaml.YAMLError as e:
raise ConfigError(f"{self.config_path}: invalid YAML: {e}") from e
return Config.load(raw)

def _yaml(self, sub: str, obj_id: str) -> Path:
return self.kb_dir / sub / f"{obj_id}.yaml"

Expand Down
Loading
Loading