Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,15 @@ All notable changes to vouch are documented here. Format follows
extra isn't installed. Opt-in via `triage.enabled: true` in `config.yaml`.
`vouch triage [proposal-id...]` mirrors it on the CLI with `--json` and
`--reverse` (#322).
- 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).

## [1.2.1] — 2026-07-06

Expand Down
20 changes: 11 additions & 9 deletions src/vouch/capabilities.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,10 +17,12 @@

_log = logging.getLogger(__name__)

# Path to the plugin manifest, relative to this module. capabilities.py lives
# at src/vouch/capabilities.py; openclaw.plugin.json lives at the repo root,
# three levels up (src/vouch/ -> src/ -> repo root).
_PLUGIN_MANIFEST_PATH = Path(__file__).resolve().parent.parent.parent / "openclaw.plugin.json"
# Path to the loader-facing package.json at the repo root, three levels up
# (src/vouch/ -> src/ -> repo root). The 2026.6 openclaw repackage moved the
# `openclaw.compat.pluginApi` floor out of openclaw.plugin.json (whose
# `openclaw.*` fields the current loader ignores) and into package.json, so
# that is now the single source of truth for host compat (#237).
_PACKAGE_JSON_PATH = Path(__file__).resolve().parent.parent.parent / "package.json"

# The full method surface this implementation exposes. Keep this list in
# sync with the MCP server + JSONL server registrations — `test_capabilities`
Expand Down Expand Up @@ -90,19 +92,19 @@


def _load_host_compat() -> dict[str, dict[str, str]]:
"""Read the `openclaw.compat` block from openclaw.plugin.json (#237).
"""Read the `openclaw.compat` block from package.json (#237).

Surfaced in `kb.capabilities` as `host_compat` so non-OpenClaw clients
can detect compat without parsing the manifest themselves. Returns an
empty dict (rather than raising) if the manifest is missing or
empty dict (rather than raising) if package.json is missing or
malformed — capabilities() must never fail to report basic info just
because the manifest moved or this is installed as a standalone wheel
without the manifest packaged alongside it.
without package.json packaged alongside it.
"""
try:
manifest = json.loads(_PLUGIN_MANIFEST_PATH.read_text(encoding="utf-8"))
manifest = json.loads(_PACKAGE_JSON_PATH.read_text(encoding="utf-8"))
except (OSError, json.JSONDecodeError) as e:
_log.debug("openclaw.plugin.json unreadable, host_compat will be empty: %s", e)
_log.debug("package.json unreadable, host_compat will be empty: %s", e)
return {}
compat = manifest.get("openclaw", {}).get("compat")
if not isinstance(compat, dict):
Expand Down
28 changes: 3 additions & 25 deletions src/vouch/context.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,8 +16,6 @@
import sqlite3
from typing import Any, Literal, cast

import yaml

from . import graph, index_db
from .models import ClaimStatus, ContextItem, ContextPack, ContextQuality
from .scoping import (
Expand All @@ -42,35 +40,15 @@

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".
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()


def _retrieve(
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 @@ -271,7 +280,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 @@ -280,6 +291,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
117 changes: 116 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 @@ -502,3 +502,118 @@ class Capabilities(BaseModel):
'{"openclaw": {"pluginApi": ">=2026.4.0"}}.'
),
)


# --- 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


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

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", "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)
29 changes: 2 additions & 27 deletions src/vouch/proposals.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,6 @@
from datetime import UTC, datetime, timedelta
from typing import Any

import yaml
from pydantic import ValidationError

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

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


@dataclass
Expand Down Expand Up @@ -342,18 +340,7 @@ def _approval_block_reason(
f"forbidden_self_approval: page kind '{page_type}' is protected — "
"it always requires a reviewer other than the proposer"
)
cfg: dict[str, Any] = {}
try:
loaded = yaml.safe_load((store.kb_dir / "config.yaml").read_text(encoding="utf-8"))
if isinstance(loaded, dict):
cfg = loaded
except Exception:
pass
review_cfg = cfg.get("review")
approver_role = (
review_cfg.get("approver_role") if isinstance(review_cfg, dict) else None
)
if approver_role != "trusted-agent":
if store.config.review.approver_role != "trusted-agent":
return (
f"forbidden_self_approval: {approved_by} cannot approve their own "
"proposal (set review.approver_role: trusted-agent in config.yaml to opt out)"
Expand Down Expand Up @@ -587,19 +574,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 @@ -262,6 +265,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