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.
What happened
recall.load_config()andcapture.load_config()both promise "Read the<namespace>:stanza; fall back to defaults" on malformed config — buttheir numeric fields pass YAML-sourced values straight through bare
int()/float()calls instead of failing soft:compile.pyalready implements the promised fail-soft contract for itsown numeric fields via a small
_coerce(value, default, cast)helperthat catches
(TypeError, ValueError)—recall.pyandcapture.pynever got that treatment for their numeric fields (only their boolean
enabledfield was fixed in an earlier round viacoerce_bool; thenumeric 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
enabledbooleanfield already behaves, rather than raising an uncaught exception.
Reproduction
Output:
Same shape for
capture.load_config()with e.g.min_observations: "three".Concrete failure scenario
recall.load_configis called unguarded from thevouch recallCLIcommand, which is wired as the SessionStart hook — so a single malformed
numeric value in
.vouch/config.yamlcrashes recall-digest injection onevery new session until the config is manually fixed.
capture.load_configis called from session-split capture and codexrollout ingestion, breaking those hook-driven flows the same way. No
existing test in
tests/test_recall.pyortests/test_capture.pycovers a malformed numeric value.
Environment
testbranch @ current HEADrollout ingestion
.vouch/stateNot 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 closedunmerged on 2026-07-29 purely for going stale against a fast-moving
testbranch (conflicts insrc/vouch/capture.pyandsrc/vouch/recall.py) — the maintainer's closing comment explicitlysaid this wasn't a judgment on the change. Re-verified independently
against current
testHEAD: only the boolean coercion was separatelyfixed since; the bare
int()/float()calls are still exactly asdescribed.
Suggested fix: promote
compile.py's existing_coerce(value, default, cast)into the sharedconfig_coerce.pymodule (already home tocoerce_bool) ascoerce_numeric(), and use it forrecall.py'smax_charsandcapture.py'smin_observations/dedup_window_seconds, matching #488's approach with added testcoverage.