Skip to content

feat(config): typed Config model + validation for .vouch/config.yaml#297

Open
minion1227 wants to merge 3 commits into
vouchdev:mainfrom
minion1227:minion_243
Open

feat(config): typed Config model + validation for .vouch/config.yaml#297
minion1227 wants to merge 3 commits into
vouchdev:mainfrom
minion1227:minion_243

Conversation

@minion1227

@minion1227 minion1227 commented Jul 1, 2026

Copy link
Copy Markdown

What changed

.vouch/config.yaml is now parsed once into a validated pydantic Config
(with nested ReviewConfig / RetrievalConfig), exposed as a cached
KBStore.config. the two readers the issue calls out — proposals'
self-approval / expire-window checks and the retrieval backend selector —
switch to store.config.<field> and drop their ad-hoc yaml.safe_load +
nested .get() + except: {} parsing. vouch doctor gains a config check.

Why

config was read defensively at each call site, so the schema was spread
across the codebase, a mistyped key (reveiw:) was silently ignored, and a
malformed value (retrieval.default_limit: "ten") silently fell back to a
default instead of surfacing. a single typed model makes config a
load-bearing, validated shape — consistent with the "prefer pydantic models
for any persisted shape" rule in CONTRIBUTING. closes #243.

acceptance from the issue:

  • a mistyped top-level key surfaces a config_unknown_key warning in
    vouch doctor (preserved, not dropped).
  • a malformed value fails fast with the offending key path
    (config.yaml: retrieval.default_limit: ...) instead of a silent fallback.
  • an existing .vouch/ with no retrieval: / review: blocks loads with
    the documented defaults — covered by round-trip tests.

What might break

nothing on disk. config.yaml's format is unchanged, the legacy
retrieval.backends list still resolves exactly as before, and a missing
file still yields all-defaults. the one behavior change is intentional and
per the issue: a genuinely malformed config value now raises ConfigError
(and vouch doctor reports config_invalid) rather than silently
defaulting. no kb.* method, on-disk layout, or audit-log shape changes.

VEP

not a surface change — internal parsing of an existing file plus two
additive vouch doctor finding codes. no VEP needed.

Tests

  • make check passes locally (ruff clean; mypy clean on touched files;
    pytest green — the only local failures are pre-existing and
    environment-only: fastapi/[web] not installed and embeddings
    installed, both of which also fail on main)
  • new behaviour has a test — tests/test_config_model.py (15 cases:
    defaults, partial configs, unknown-key warning, malformed-value
    failure, legacy-backends resolution, doctor integration)
  • CHANGELOG.md updated under ## [Unreleased]

Summary by CodeRabbit

  • New Features

    • Added typed configuration handling with validation, cached loading, and sensible defaults for missing sections.
    • Improved backend selection to honor validated config values and legacy fallback entries.
  • Bug Fixes

    • Malformed config values now fail fast with clear path-based error messages.
    • Unknown config keys are now surfaced as warnings instead of being silently ignored.
    • Health checks now report invalid configuration and nested typos more clearly.
  • Tests

    • Added coverage for config parsing, defaults, validation errors, caching, and health-check reporting.

`.vouch/config.yaml` was read as an untyped dict at each call site, with
nested `.get()` guards and silent `except: {}` fallbacks — the schema was
spread across the codebase and typos (`reveiw:`) were silently dropped.

add a validated pydantic `Config` (with nested `ReviewConfig` and
`RetrievalConfig`), parsed once and exposed as a cached `KBStore.config`.
migrate the readers the issue names — proposals' self-approval /
expire-window checks and the retrieval backend selector — onto it, and
delete their ad-hoc parsing. a malformed value (e.g.
`retrieval.default_limit: "ten"`) now fails fast with the offending key
path instead of falling back, and `vouch doctor` surfaces unknown
top-level keys as likely typos.

sections owned by their own readers (serve, volunteer, mcp) stay loose so
they neither break nor trip the unknown-key check, and the legacy
`retrieval.backends` list still resolves as before, so an existing
`.vouch/` loads with documented defaults and no on-disk change.

closes vouchdev#243
@github-actions github-actions Bot added docs documentation, specs, examples, and repo guidance storage kb storage, migrations, schemas, and proposals retrieval context, search, synthesis, and evaluation tests tests and fixtures size: M 200-499 changed non-doc lines labels Jul 1, 2026
@coderabbitai

coderabbitai Bot commented Jul 1, 2026

Copy link
Copy Markdown

Review Change Stack

Warning

Review limit reached

@minion1227, you've reached your PR review limit, so we couldn't start this review.

Next review available in: 30 minutes

Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available.
You're only billed for reviews past your plan's rate limits ($0.25/file).

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews.

How do review limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 8229dead-2824-4801-a040-8a493c361b7b

📥 Commits

Reviewing files that changed from the base of the PR and between 930a866 and bb7b079.

📒 Files selected for processing (3)
  • tests/test_health.py
  • tests/test_models.py
  • tests/test_storage.py
📝 Walkthrough

Walkthrough

Introduces a typed pydantic Config model (with nested ReviewConfig/RetrievalConfig) parsed once from .vouch/config.yaml, exposed as KBStore.config (cached_property). Migrates context.py and proposals.py to consume it, adds doctor() diagnostics for invalid/unknown config keys, and adds tests plus a changelog entry.

Changes

Typed Config Model

Layer / File(s) Summary
Config, ReviewConfig, RetrievalConfig models
src/vouch/models.py
Adds ConfigError, ReviewConfig, RetrievalConfig (with resolved_backend()), and Config (with load() and unknown_keys()) pydantic models with updated imports.
KBStore.config cached loading
src/vouch/storage.py
KBStore.config becomes a cached_property reading config.yaml, returning defaults if missing, raising ConfigError on read/parse failure, else returning Config.load(...).
Backend resolution and proposal review-gate migration
src/vouch/context.py, src/vouch/proposals.py
context.py resolves backend via store.config.retrieval.resolved_backend(); proposals.py reads store.config.review.approver_role/expire_pending_after_days directly, removing local yaml parsing and fallback constants.
doctor() config diagnostics
src/vouch/health.py
doctor() loads store.config, emits config_invalid error on ConfigError, and config_unknown_key warnings for each unknown key.
Tests and changelog
tests/test_models.py, CHANGELOG.md
Adds tests for Config.load, KBStore config integration, and doctor() diagnostics; documents the new typed config model in the changelog.

Sequence Diagram(s)

sequenceDiagram
  participant Context as context.py
  participant Proposals as proposals.py
  participant KBStore
  participant Config

  Context->>KBStore: store.config
  KBStore->>Config: Config.load(yaml)
  Config-->>KBStore: typed Config
  KBStore-->>Context: cached Config
  Context->>Config: retrieval.resolved_backend()
  Proposals->>KBStore: store.config
  Proposals->>Config: review.approver_role / expire_pending_after_days
Loading

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~30 minutes

Poem

A rabbit found a config stray,
Untyped dicts led minds astray.
Now Pydantic guards each field,
Typos surface, none concealed. 🐇
With doctor() watching, warm and wise,
Config bugs no longer hide in disguise!

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 14.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly describes the main change: introducing a typed, validated config model for .vouch/config.yaml.
Linked Issues check ✅ Passed The PR matches #243 by adding typed config models, cached store loading, doctor warnings, clear validation errors, and fallback defaults.
Out of Scope Changes check ✅ Passed The changes stay focused on config typing, validation, and call-site migration, with no clear unrelated scope added.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Actionable comments posted: 1

🧹 Nitpick comments (4)
src/vouch/models.py (1)

477-477: 🎯 Functional Correctness | 🔵 Trivial | ⚡ Quick win

Nested review:/retrieval: typos are silently swallowed, unlike top-level ones.

ReviewConfig and RetrievalConfig both declare extra="allow", but Config.unknown_keys() (Line 559-561) only inspects the top-level __pydantic_extra__. A typo like review: {expier_pending_after_days: 1} lands as an untracked extra field on ReviewConfig — never raised as an error, never surfaced by unknown_keys(), and never shown by vouch doctor. This reproduces, one level deeper, exactly the silent-typo problem this PR is meant to close.

Consider extending unknown_keys() to also walk review.__pydantic_extra__ / retrieval.__pydantic_extra__ (prefixed, e.g. "review.foo"), or set extra="forbid" on ReviewConfig/RetrievalConfig if forward-compat isn't needed there.

♻️ Sketch: surface nested unknown keys too
     def unknown_keys(self) -> list[str]:
         """Top-level keys outside the known schema (likely typos)."""
-        return sorted(self.__pydantic_extra__ or {})
+        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)

Also applies to: 489-489, 526-526, 559-561

🤖 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/models.py` at line 477, Nested `review:`/`retrieval:` typos are
still being hidden because `Config.unknown_keys()` only checks the top-level
`__pydantic_extra__`. Update `unknown_keys()` in `Config` to also inspect
`review.__pydantic_extra__` and `retrieval.__pydantic_extra__`, prefixing nested
entries like `review.foo` and `retrieval.bar`, or alternatively tighten
`ReviewConfig` and `RetrievalConfig` by changing their `model_config` from
`extra="allow"` to `extra="forbid"` if nested forward-compatibility is not
needed. Use the existing `Config`, `ReviewConfig`, and `RetrievalConfig` symbols
to keep the change localized.
tests/test_config_model.py (2)

1-138: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Test file name doesn't mirror the module it targets.

Guidelines require test files to mirror module names as tests/test_<module>.py. This file exercises vouch.models.Config (and integration with storage.py/health.py) but is named test_config_model.py rather than test_models.py.

As per coding guidelines, "Test file names must mirror module names using the tests/test_<module>.py convention."

🤖 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 `@tests/test_config_model.py` around lines 1 - 138, Rename the test file so it
mirrors the target module name using the required tests/test_<module>.py
convention; this suite primarily covers vouch.models.Config, so the filename
should match the models module rather than the current config_model name. Update
the test file name consistently and keep the existing Config, KBStore, and
doctor tests in place.

Source: Coding guidelines


98-116: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Consider adding a same-instance caching test for KBStore.config.

The upstream KBStore.config docstring notes it's a cached_property, but no test here asserts that repeated access on the same store instance returns the same cached value (only cross-instance behavior via fresh KBStore(tmp_path) is exercised). A quick assert store.config is store.config would lock in the caching contract.

🤖 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 `@tests/test_config_model.py` around lines 98 - 116, Add a same-instance
caching assertion for KBStore.config to cover the cached_property contract. In
the existing KBStore config tests, extend the relevant test around
KBStore.config so it verifies repeated access on the same store instance returns
the identical object (for example, by checking the same-instance identity on
store.config). Keep the current cross-instance checks and malformed-config
coverage unchanged.
src/vouch/health.py (1)

222-235: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Consider merging with the existing "Config sanity" block.

This new block and the pre-existing missing_config check (Lines 257-265) both gate on store.config_path.exists() but are now split apart by the unrelated "Source integrity" section. Consolidating all config-related findings (missing_config, config_invalid, config_unknown_key) into one block would make the config-diagnostics logic easier to follow and maintain.

♻️ Proposed consolidation
-    # Config validity — a malformed value is an error; an unknown top-level
-    # key is a likely typo silently ignored before `#243`, now surfaced.
-    if store.config_path.exists():
-        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",
-                ))
-
     # Source integrity (content hash).
     ...
-    # Config sanity.
-    if not store.config_path.exists():
+    # Config sanity — missing file is fine (defaults apply); a malformed
+    # value is an error; an unknown top-level key is a likely typo silently
+    # ignored before `#243`, now surfaced.
+    if not store.config_path.exists():
         report.findings.append(
             Finding(
                 "error",
                 "missing_config",
                 "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",
+                ))
🤖 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/health.py` around lines 222 - 235, The config diagnostics in
health.py are split across separate sections even though they all depend on
store.config_path.exists(); merge the new config validity logic with the
existing Config sanity block so missing_config, config_invalid, and
config_unknown_key are handled together. Update the report-building flow in the
health check to keep all config-related findings adjacent and use the existing
store.config and ConfigError handling within that single block.
🤖 Prompt for all review comments with 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.

Inline comments:
In `@src/vouch/storage.py`:
- Around line 237-253: The config parsing in KBStore.config only converts
FileNotFoundError and OSError, so malformed YAML from _yaml_load can escape as
yaml.YAMLError and crash doctor(). Update the config property to catch
yaml.YAMLError around the _yaml_load(text) / Config.load path and re-raise it as
ConfigError with the config path context, so doctor() continues to handle
invalid config.yaml via its existing ConfigError path.

---

Nitpick comments:
In `@src/vouch/health.py`:
- Around line 222-235: The config diagnostics in health.py are split across
separate sections even though they all depend on store.config_path.exists();
merge the new config validity logic with the existing Config sanity block so
missing_config, config_invalid, and config_unknown_key are handled together.
Update the report-building flow in the health check to keep all config-related
findings adjacent and use the existing store.config and ConfigError handling
within that single block.

In `@src/vouch/models.py`:
- Line 477: Nested `review:`/`retrieval:` typos are still being hidden because
`Config.unknown_keys()` only checks the top-level `__pydantic_extra__`. Update
`unknown_keys()` in `Config` to also inspect `review.__pydantic_extra__` and
`retrieval.__pydantic_extra__`, prefixing nested entries like `review.foo` and
`retrieval.bar`, or alternatively tighten `ReviewConfig` and `RetrievalConfig`
by changing their `model_config` from `extra="allow"` to `extra="forbid"` if
nested forward-compatibility is not needed. Use the existing `Config`,
`ReviewConfig`, and `RetrievalConfig` symbols to keep the change localized.

In `@tests/test_config_model.py`:
- Around line 1-138: Rename the test file so it mirrors the target module name
using the required tests/test_<module>.py convention; this suite primarily
covers vouch.models.Config, so the filename should match the models module
rather than the current config_model name. Update the test file name
consistently and keep the existing Config, KBStore, and doctor tests in place.
- Around line 98-116: Add a same-instance caching assertion for KBStore.config
to cover the cached_property contract. In the existing KBStore config tests,
extend the relevant test around KBStore.config so it verifies repeated access on
the same store instance returns the identical object (for example, by checking
the same-instance identity on store.config). Keep the current cross-instance
checks and malformed-config coverage unchanged.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 2380c7a9-8ea3-414f-956d-30f189b46447

📥 Commits

Reviewing files that changed from the base of the PR and between 94e84b1 and 692415a.

📒 Files selected for processing (7)
  • CHANGELOG.md
  • src/vouch/context.py
  • src/vouch/health.py
  • src/vouch/models.py
  • src/vouch/proposals.py
  • src/vouch/storage.py
  • tests/test_config_model.py

Comment thread src/vouch/storage.py
address review on vouchdev#297:

- `KBStore.config` now catches `yaml.YAMLError` from a syntactically
  broken config.yaml and re-raises it as `ConfigError`, so `vouch doctor`
  reports `config_invalid` instead of crashing on malformed YAML.
- `Config.unknown_keys()` walks the `review` and `retrieval` blocks too and
  returns dotted paths, so a typo one level deep
  (`review.expier_pending_after_days`) is flagged rather than silently
  swallowed — the same problem this change closes at the top level.
- consolidate the config diagnostics in `vouch doctor` into the single
  "config sanity" block.
- rename the test module to `tests/test_models.py` per the mirror-the-module
  convention, and add cases for malformed-yaml handling, nested-typo
  surfacing, and the cached_property contract.
@minion1227

Copy link
Copy Markdown
Author

thanks for the review — pushed 930a866 addressing all of it:

  • malformed yaml crash (actionable): KBStore.config now catches
    yaml.YAMLError and re-raises as ConfigError, so vouch doctor reports
    config_invalid rather than crashing. added a test that feeds broken yaml
    syntax through doctor().
  • nested typos: unknown_keys() now walks the review/retrieval blocks
    and returns dotted paths (review.expier_pending_after_days), so a typo one
    level deep is surfaced too. new test covers it.
  • test filename: renamed to tests/test_models.py.
  • caching contract: added assert store.config is store.config.
  • health.py: folded the config-validity checks into the single config
    sanity block.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
tests/test_models.py (1)

128-190: 📐 Maintainability & Code Quality | 🟠 Major | ⚡ Quick win

Cross-module tests placed in test_models.py.

Tests at Lines 128-141 (KBStore.config caching/ConfigError) and Lines 172-190 (doctor() diagnostics) exercise storage.py and health.py behavior respectively, not models.py. Per coding guidelines, test file names must mirror the module under test. Consider splitting these into tests/test_storage.py and tests/test_health.py, keeping test_models.py scoped to Config/ReviewConfig/RetrievalConfig unit tests.

As per coding guidelines, "Test file names must mirror module names using the tests/test_<module>.py convention."

🤖 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 `@tests/test_models.py` around lines 128 - 190, The tests in test_models.py are
covering KBStore.config behavior and doctor() health checks from other modules,
so move the KBStore-related cases into the storage test module and the doctor()
cases into the health test module. Keep test_models.py focused on the models
classes it actually exercises (such as Config, ReviewConfig, and
RetrievalConfig), and preserve the existing assertions while relocating the
relevant test functions by their names.

Source: Coding guidelines

🤖 Prompt for all review comments with 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.

Outside diff comments:
In `@tests/test_models.py`:
- Around line 128-190: The tests in test_models.py are covering KBStore.config
behavior and doctor() health checks from other modules, so move the
KBStore-related cases into the storage test module and the doctor() cases into
the health test module. Keep test_models.py focused on the models classes it
actually exercises (such as Config, ReviewConfig, and RetrievalConfig), and
preserve the existing assertions while relocating the relevant test functions by
their names.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: b95b459e-3531-4018-8aea-627113d158cd

📥 Commits

Reviewing files that changed from the base of the PR and between 692415a and 930a866.

📒 Files selected for processing (4)
  • src/vouch/health.py
  • src/vouch/models.py
  • src/vouch/storage.py
  • tests/test_models.py
🚧 Files skipped from review as they are similar to previous changes (3)
  • src/vouch/health.py
  • src/vouch/models.py
  • src/vouch/storage.py

address the re-review caution on vouchdev#297: renaming to test_models.py left
`KBStore.config` and `doctor()` cases in the wrong module's test file.

- keep tests/test_models.py scoped to Config/ReviewConfig/RetrievalConfig
  unit tests
- move the KBStore.config accessor cases into tests/test_storage.py
- move the vouch doctor config-diagnostic cases into tests/test_health.py

no production code change; all assertions preserved.
@minion1227

Copy link
Copy Markdown
Author

addressed the re-review caution in bb7b079 — the rename to test_models.py
had left cross-module cases behind. split them to mirror the module under
test: test_models.py now holds only the Config/ReviewConfig/
RetrievalConfig unit tests, the KBStore.config cases moved to
test_storage.py, and the vouch doctor diagnostics to test_health.py.
no production code change; assertions preserved.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

docs documentation, specs, examples, and repo guidance retrieval context, search, synthesis, and evaluation size: M 200-499 changed non-doc lines storage kb storage, migrations, schemas, and proposals tests tests and fixtures

Projects

None yet

Development

Successfully merging this pull request may close these issues.

feat(config): typed Config model + validation for .vouch/config.yaml

1 participant