Skip to content

fix(config): recall/capture crash on malformed numeric config values instead of falling back to defaults #685

Description

@philluiz2323

What happened

recall.load_config() and capture.load_config() both promise "Read the
<namespace>: stanza; fall back to defaults" on malformed config — but
their numeric fields pass YAML-sourced values straight through bare
int()/float() calls instead of failing soft:

# src/vouch/recall.py (before fix)
return RecallConfig(
    enabled=coerce_bool(raw.get("enabled", DEFAULT_ENABLED), DEFAULT_ENABLED),
    max_chars=int(raw.get("max_chars", DEFAULT_MAX_CHARS)),
)
# src/vouch/capture.py (before fix)
return CaptureConfig(
    enabled=coerce_bool(raw.get("enabled", DEFAULT_ENABLED), DEFAULT_ENABLED),
    min_observations=int(raw.get("min_observations", DEFAULT_MIN_OBSERVATIONS)),
    dedup_window_seconds=float(
        raw.get("dedup_window_seconds", DEFAULT_DEDUP_WINDOW_SECONDS)
    ),
    ...
)

compile.py already implements the promised fail-soft contract for its
own numeric fields via a small _coerce(value, default, cast) helper
that catches (TypeError, ValueError)recall.py and capture.py
never got that treatment for their numeric fields (only their boolean
enabled field was fixed in an earlier round via coerce_bool; the
numeric fields were missed).

What you expected

A malformed numeric config value should degrade to the field's default,
matching the documented contract and matching how the enabled boolean
field already behaves, rather than raising an uncaught exception.

Reproduction

import tempfile, pathlib
from vouch.storage import KBStore
from vouch import recall, capture

d = pathlib.Path(tempfile.mkdtemp())
store = KBStore.init(d)
store.config_path.write_text(
    store.config_path.read_text(encoding="utf-8")
    + '\nrecall:\n  max_chars: "12,000"\n',
    encoding="utf-8",
)
recall.load_config(store)  # raises

Output:

ValueError: invalid literal for int() with base 10: '12,000'

Same shape for capture.load_config() with e.g.
min_observations: "three".

Concrete failure scenario

recall.load_config is called unguarded from the vouch recall CLI
command, which is wired as the SessionStart hook — so a single malformed
numeric value in .vouch/config.yaml crashes recall-digest injection on
every new session until the config is manually fixed.
capture.load_config is called from session-split capture and codex
rollout ingestion, breaking those hook-driven flows the same way. No
existing test in tests/test_recall.py or tests/test_capture.py
covers a malformed numeric value.

Environment

  • vouch version: test branch @ current HEAD
  • Python version: 3.11+
  • OS: any
  • Host: any — SessionStart recall hook, session-split capture, codex
    rollout ingestion

.vouch/ state

Not required to reproduce — the repro above uses a fresh temp KB.

Anything else

This exact defect and fix were previously submitted as #488
(fix(config): fall back to defaults on malformed numeric config values, CodeRabbit-reviewed with no substantive objections) but closed
unmerged on 2026-07-29 purely for going stale against a fast-moving
test branch (conflicts in src/vouch/capture.py and
src/vouch/recall.py) — the maintainer's closing comment explicitly
said this wasn't a judgment on the change. Re-verified independently
against current test HEAD: only the boolean coercion was separately
fixed since; the bare int()/float() calls are still exactly as
described.

Suggested fix: promote compile.py's existing _coerce(value, default, cast) into the shared config_coerce.py module (already home to
coerce_bool) as coerce_numeric(), and use it for recall.py's
max_chars and capture.py's min_observations/
dedup_window_seconds, matching #488's approach with added test
coverage.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions