Skip to content

fix: crystallize exits non-zero and warns on approval failures - #58

Closed
Tet-9 wants to merge 63 commits into
vouchdev:mainfrom
Tet-9:fix/57-crystallize-exit-nonzero-on-failure
Closed

fix: crystallize exits non-zero and warns on approval failures#58
Tet-9 wants to merge 63 commits into
vouchdev:mainfrom
Tet-9:fix/57-crystallize-exit-nonzero-on-failure

Conversation

@Tet-9

@Tet-9 Tet-9 commented May 22, 2026

Copy link
Copy Markdown
Contributor

Summary

crystallize() in sessions.py catches every approve() exception into a failures list. The CLI then printed the JSON result and exited 0 regardless — a complete failure where every proposal failed to approve was indistinguishable from a successful crystallize without carefully reading the JSON output.

Fix

After emitting the JSON result, inspect result["failures"]:

  • All failed (approved == []): print a clear error to stderr and sys.exit(1)
  • Partial failure: print a warning to stderr, exit 0

The JSON output is unchanged so any downstream tooling parsing it is unaffected. The error and warning go to stderr only.

Testing

108 tests passing, no regressions.

Fixes #57

Summary by CodeRabbit

  • New Features

    • Semantic search powered by embedding models with hybrid retrieval fusion strategies
    • Review-gated proposal workflow with mandatory human approval
    • Bundle import path-safety validation
  • Bug Fixes

    • Fixed bundle import vulnerability (CVE-2007-4559): rejects tar paths escaping target directory
    • crystallize command now exits with error status when all proposals fail approval
    • Prevented accidental artifact overwrites during approval process
    • Source verification gracefully handles missing/unreadable stored content
  • Documentation

    • Added comprehensive specification with version snapshots
    • New end-to-end demo and walkthrough guide
    • Improved README with usage guidance and feature matrix

plind-junior and others added 30 commits May 19, 2026 15:44
…nd-label-14

fix: track backend label separately in JSONL search handler (vouchdev#14)
…ion-12

fix: add existence guards to all put_* methods (vouchdev#12)
…nt-artifact-guard

fix(proposals): refuse to overwrite existing artifact on approve
kb.register_source_from_path read the contents of any file the process
could access, letting an agent register /etc/passwd, ~/.ssh/id_rsa,
~/.aws/credentials etc. as a "source" and then retrieve the bytes via
kb.cite or kb.list_sources.

Resolve the path (following symlinks) and require it to be inside the
KB root before reading. Adds KBStore.resolve_under_root() so both the
JSONL handler and the MCP tool share one containment check.

Fixes vouchdev#10
The CLI handlers for approve and reject caught only
(ArtifactNotFoundError, ValueError) but proposals.approve() /
proposals.reject() raise ProposalError -- a RuntimeError subclass. The
four propose-* shortcuts caught nothing at all. Result: a double-
approve, an empty rejection reason, an empty claim text, or an unknown
source id surfaced a raw Python traceback instead of the one-line
`Error: ...` the rest of the CLI emits.

Add a small `_cli_errors` context manager that translates
ArtifactNotFoundError / ValueError / ProposalError / LifecycleError
into click.ClickException, and apply it to every command that calls
into proposals, lifecycle, sessions, or storage. The MCP and JSONL
servers already do the equivalent in their own envelopes; this brings
the human-facing surface in line.

Adds tests/test_cli.py with regressions for approve, reject, propose-
claim, propose-entity, and show.
ruff B904 requires `raise ... from err` inside an except block so the
original exception is preserved on the chained __cause__. Without it,
the CI lint step rejects the file. The seven sites are pre-existing
(put_claim, put_page, put_entity, put_relation, put_evidence,
put_session, put_proposal); this PR inherited the failure from main
rather than introducing it, but the path traversal fix cannot land
until lint is green, so the cleanup happens here.

No behavior change: the chained exception carries .__cause__ but the
public ValueError message and type are unchanged.
verify_source() caught FileNotFoundError, but store.read_source_content()
raises ArtifactNotFoundError (a KeyError subclass) when the content blob
is missing -- so the "stored content missing" graceful path never ran
and a single broken source crashed the entire verify_all() sweep,
breaking `vouch source verify` and `vouch doctor`.

The same call can also raise OSError (permission denied, TOCTOU race
between exists() and read_bytes(), underlying I/O error) which was
likewise unhandled. Catch both: the existing "missing" path keeps its
note, and OSError surfaces as "stored content unreadable: <reason>".

Adds three regression tests:
- missing-content blob -> graceful per-source failure
- unreadable stored content (monkeypatched PermissionError) -> graceful
  per-source failure with the underlying reason in the note
- mixed sweep with one good + one broken source -> verify_all returns
  both results instead of aborting at the first failure

Fixes vouchdev#30
The CI workflow runs `ruff check`, `mypy`, then `pytest`, and the
fix/cli-clean-domain-errors branch was failing all three on
pre-existing main-branch issues unrelated to the CLI change itself.

* ruff: add `from e` to the seven `raise ValueError(...) from
  FileExistsError` re-raises added by the recent exclusive-create
  guards in storage.put_* (B904), and let ruff re-sort the cli.py
  import block (I001).
* mypy: narrow the `kind` value flowing into `ContextItem(type=...)`
  with a `Literal` cast so the strict-typed field accepts what the
  search backends actually return.
* pytest: `session_end()` mutates a session that `session_start()` has
  already written, but `put_session()` now uses exclusive create and
  rejects the second write. Add `KBStore.update_session()` mirroring
  `update_claim` and have `session_end` call it; existing test_sessions
  coverage now passes again.

No behavior change for the CLI surface — these are infrastructure
fixes so the existing test_sessions / lint / type assertions pass on
this branch.
The B904 lint failures were already addressed in 2a439a5, but the CI
matrix still fails on two pre-existing main-branch issues:

* mypy: context.py:64 passed `kind: str` into ContextItem.type, which
  is Literal["claim","page","entity","relation","source"]. Narrow it
  with a typing.cast so strict mode accepts the search-backend output.
* pytest: session_end() mutates a session that session_start() already
  wrote, but put_session() switched to exclusive create and rejects
  the second write. Add KBStore.update_session() mirroring update_claim
  and have session_end call it; test_sessions coverage passes again.

No behavior change for the path-traversal fix itself.
CodeRabbit on PR vouchdev#28 noted that resolve_under_root() only validated a
pathname snapshot. Callers then re-opened the same name with .is_file()
and .read_bytes(), so an attacker who can swap the resolved path for a
symlink between the containment check and the read can still
exfiltrate an out-of-root file via kb.register_source_from_path.

Collapse the validate-then-read into a single trusted helper
`KBStore.read_under_root(path)` that returns `(resolved, bytes)`:

1. Path.resolve() chases pre-existing symlinks and the resulting target
   is checked for containment (existing behaviour -- legitimate in-root
   symlinks still work).
2. The read goes through `os.open(resolved, O_RDONLY | O_NOFOLLOW)` so
   a fresh symlink placed at the resolved name *after* the check fails
   with ELOOP rather than following the swap.
3. `fstat` + `S_ISREG` rejects directories / device nodes / pipes
   atomically on the same fd, replacing the racy `is_file()` test the
   callers used to do.

Both register_source_from_path handlers (MCP + JSONL) switch to the
new helper and drop their now-redundant follow-up checks.

Adds tests:
- symlink swapped into the resolved name -> rejected via ELOOP
- directory at a valid path -> rejected via S_ISREG

Existing "outside the root" and "inside the root" tests still pass.
…path-traversal

fix(server): block path traversal in register_source_from_path
ruff B904 requires `raise ... from err` inside an except block so the
original exception is preserved on the chained __cause__. Without it,
the CI lint step rejects the file. The seven sites are pre-existing
(put_claim, put_page, put_entity, put_relation, put_evidence,
put_session, put_proposal); this PR inherited the failure from main
rather than introducing it, but the path traversal fix cannot land
until lint is green, so the cleanup happens here.

No behavior change: the chained exception carries .__cause__ but the
public ValueError message and type are unchanged.
The B904 lint failures were already addressed in 2a439a5, but the CI
matrix still fails on two pre-existing main-branch issues:

* mypy: context.py:64 passed `kind: str` into ContextItem.type, which
  is Literal["claim","page","entity","relation","source"]. Narrow it
  with a typing.cast so strict mode accepts the search-backend output.
* pytest: session_end() mutates a session that session_start() already
  wrote, but put_session() switched to exclusive create and rejects
  the second write. Add KBStore.update_session() mirroring update_claim
  and have session_end call it; test_sessions coverage passes again.

No behavior change for the path-traversal fix itself.
Resolve conflicts in context.py, sessions.py, storage.py (keep main).

fix: block bad writes in import_apply per review feedback (vouchdev#13)

import_apply now captures schema validation issues and skips the file
instead of passing a throwaway list.
…rors

fix(cli): translate domain errors into clean ClickException output
…raversal

fix(bundle): reject path traversal in import (CVE-2007-4559, vouchdev#9)
…ception

fix(verify): catch ArtifactNotFoundError on missing stored content
fix: catch ArtifactNotFoundError in verify (vouchdev#30) and validate bundle content on import (vouchdev#13)
Approved design for feat/semantic-search. Embedding (sentence-
transformers all-mpnet-base-v2) becomes the primary search backend
with FTS5 as fallback. Synchronous-at-write indexing across all six
artifact types (claim, page, source, entity, relation, evidence).

Maximally functional scope (~3000 LOC): pluggable model adapter
registry, sqlite-vec ANN with NumPy fallback, cross-encoder rerank,
HyDE query expansion, ingest-time duplicate detection, model-identity
migration, recall/MRR/nDCG eval harness, and full CLI/MCP/JSONL parity.

The writing-plans step will turn the rollout order in section 16
into concrete phased tasks.
32-task TDD plan for the semantic-search feature: foundation, storage,
write hooks across all six artifact types, semantic-primary search
integration in MCP/JSONL/CLI, RRF fusion + hybrid, cross-encoder rerank,
HyDE expansion, ingest-time duplicate detection, model-identity
migration, recall/MRR/nDCG scorer, end-to-end integration test, and
user docs.

Each task: failing test, minimal implementation, passing test, commit.
MockEmbedder test double keeps the unit suite fast; the real model is
exercised only under @pytest.mark.integration.

The evaluation module is named scorer.py rather than eval.py to avoid
shadowing the Python builtin and to keep static analysers quiet; the
user-facing CLI subcommand remains `vouch eval embedding` (the Click
group is registered under the name "eval", with the Python identifier
eval_group).
CI ran ruff which flagged SIM105 on the three try/except ImportError
guard blocks in src/vouch/embeddings/__init__.py. Replace each with
`contextlib.suppress(ImportError)` -- same semantics, satisfies ruff.

Also add mypy overrides for numpy / sqlite_vec / sentence_transformers /
fastembed so the type check passes in the base [dev] CI install where
those optional extras aren't present. The embedding code paths that
import these libraries are only reached when the extras are installed
at runtime; for the static type check the missing stubs are noise.
CI runs `pip install -e '.[dev]'` which deliberately excludes the
optional `[embeddings]` extras. tests/embeddings/test_*.py modules
import numpy at top level, so pytest collection fails with
ModuleNotFoundError before any test can be deselected.

Add tests/embeddings/conftest.py with `pytest.importorskip("numpy")`
so the entire embeddings test directory skips gracefully when numpy
is absent. Once `pip install vouch[embeddings]` (or numpy itself) is
present, the skip is a no-op and the tests run normally.
`_validate_content` in bundle.py uses the path's first directory
component to look up a Pydantic validator. For sources, both
`sources/<sha>/meta.yaml` (the Source model) and
`sources/<sha>/content` (raw opaque bytes) hit the same "sources"
key, so the validator was being run on the raw content bytes and
failing with "1 validation error for Source".

Add an early return for any non-meta.yaml path under `sources/` so
opaque content bytes are not Pydantic-validated. Only the Source
metadata file is checked, which matches the original intent of the
PR vouchdev#13 validation.

Unblocks three pre-existing test_bundle.py failures inherited from
upstream main.
dripsmvcp and others added 27 commits May 20, 2026 22:42
…embedding

Per CodeRabbit Critical on PR vouchdev#39/40/43: SQLite does not allow a
column alias defined in the SELECT list to be referenced in the
WHERE clause of the same query. The old form

  SELECT ... 1.0 - vec_distance_cosine(vec, ?) AS score ...
  WHERE ... AND score >= ?

raised `no such column: score` at runtime; the catch-all
`except sqlite3.OperationalError` silently swallowed it and made
every call fall through to the NumPy brute-force path, which works
but defeats the whole point of installing sqlite-vec.

Wrap the projection in a subquery so the WHERE clause sees the alias.

No behavior change for callers that have already been using the
fallback (NumPy still produces correct results); the ANN code path
now actually runs when sqlite-vec is loaded.
The hybrid backend branch in kb_search (server.py) and _h_search
(jsonl_server.py) lazily imports vouch.embeddings.fusion (added in
Phase 5). On Phase 4's branch that module doesn't exist yet, so mypy
emits import-untyped. Mark each import with `# type: ignore` and let
ruff auto-format the line.
The KBStore._embed_and_store helper lazily imports
vouch.embeddings.dedup (added in Phase 6). On Phase 3's branch that
module does not yet exist, so mypy treats it as untyped and errors out.
Mark the import with `# type: ignore[import-not-found,import-untyped,
unused-ignore]` so the type check passes here and stays silent once
Phase 6 lands.
…ase-2-storage

feat(embeddings): storage layer — state.db schema + vector search + query cache
…ase-3-write-hooks

feat(embeddings): write-time embedding hooks across all six artifact kinds
…ase-4-read-integration

feat(embeddings): semantic-primary kb.search and JSONL parity
The CLI propose-* commands and most actor sites called _whoami(), which only
checked VOUCH_USER and the OS user — VOUCH_AGENT was silently ignored. The
MCP and JSONL servers (server.py, jsonl_server.py) and session_start already
honour VOUCH_AGENT, so multi-agent attribution broke as soon as you used the
CLI to propose. Prefer VOUCH_AGENT over VOUCH_USER over the OS user so the
recorded proposed_by / audit actor matches across all three transports.
Real propose -> review -> commit -> retrieve loop captured from a sandbox
run on 2026-05-21. Includes on-disk YAML, audit log lines, and an honest
note about the literal substring backend limitation pre-embeddings.
… hook

Bundles the substantive correctness fixes flagged by CodeRabbit on PR vouchdev#41.
CI was already green; these are latent bugs the review caught.

storage._embed_and_store
  - Re-embed when the active embedder model changes, not only when the
    content hash changes. The previous short-circuit kept stale vectors
    on disk after a model swap, mixing document embeddings from one
    model with queries from another.
  - Truly best-effort: catch any exception during encode/put/meta/dedup
    so a hook failure can't bubble back to the caller and leave an
    artifact persisted-but-API-errored.

index_db.reset
  - Also clears embedding_index, query_embedding_cache, embedding_dupes,
    and embedding_* keys from index_meta. Otherwise `vouch index` left
    orphaned hits behind after artifacts were removed.

index_db.search_embedding (Python fallback)
  - Normalize the stored vector by its own L2 norm before scoring, so
    rankings match the sqlite-vec `1 - vec_distance_cosine` path. The
    previous raw dot product made magnitude leak into the score.

index_db.search_semantic
  - Invalidate the query-vector cache entry when the embedder dim no
    longer matches what's on disk; otherwise a model swap returned stale
    vectors living in the wrong space.

server.kb_search + jsonl_server._h_search (hybrid branch)
  - Hybrid now passes min_score into search_semantic (consistent with
    embedding/auto branches) and wraps the FTS lookup in try/except so
    an FTS5 error doesn't fail the whole request.
  - JSONL transport rejects unknown backend values with a clear error
    instead of silently returning []; matches MCP transport behavior.

embeddings.fusion
  - rrf_fuse validates limit >= 0 and k >= 0 (k = -rank would divide by
    zero). weighted_fuse validates limit >= 0.

Tests
  - Three new fusion guard tests cover the negative-limit and
    negative-k paths.
…ase-5-fusion

feat(embeddings): hybrid fusion strategies (RRF, weighted, normalized)
crystallize() swallows every approve() exception into a failures list
and the CLI printed the JSON result and exited 0 regardless of outcome.
A complete failure — all proposals failing to approve — was
indistinguishable from success without carefully inspecting the JSON.

After emitting the JSON result, check result[failures]:
- All failed (approved == 0): print error to stderr and sys.exit(1)
- Partial failure: print warning to stderr, exit 0

Fixes vouchdev#57
@coderabbitai

coderabbitai Bot commented May 22, 2026

Copy link
Copy Markdown
📝 Walkthrough

Walkthrough

This PR implements embedding-based semantic search with intelligent backend fallback, hardens security against path traversal and TOCTOU attacks, centralizes CLI error handling with improved crystallize exit codes, and documents the complete vouch protocol specification with versioned snapshots. The changes span ~4,700 lines across storage, indexing, server routing, security, CLI, and comprehensive specification/documentation artifacts.

Changes

Embedding Foundation & Semantic Search

Layer / File(s) Summary
Embedding Registry & Adapter System
src/vouch/embeddings/base.py, st_mpnet.py, st_minilm.py, fastembed_bge.py, __init__.py
Embedder abstract base class with encode()/encode_batch(), default model registration, three pluggable sentence-transformer/fastembed adapters (768-dim MPNet, 384-dim MiniLM, 384-dim BGE ONNX), lazy imports avoiding hard dependencies.
Query Caching & Fusion Strategies
src/vouch/embeddings/cache.py, fusion.py
SQLite-backed LRU query cache with hit tracking/eviction; three hybrid fusion strategies (RRF, weighted, normalized) to combine semantic and FTS results with min-max normalization and deduplication.
Index Database Embedding Tables & Search
src/vouch/index_db.py
Extended schema with embedding_index, query_embedding_cache, embedding_dupes, and index_meta tables; put_embedding, get_embedding, search_embedding (sqlite-vec + fallback), and search_semantic for end-to-end encoding/caching/ranking.

Search Integration & Write-Time Persistence

Layer / File(s) Summary
Storage Write-Time Embedding Hook
src/vouch/storage.py
read_under_root for safe file reading with O_NOFOLLOW and TOCTOU defense; _embed_and_store hook called after writes to claims, pages, sources, entities, relations, evidence—computes embeddings, skips on content hash/model match, writes to index, swallows failures.
Search Backend Routing
src/vouch/server.py, src/vouch/jsonl_server.py
Updated kb_search and kb.search to accept backend selector (auto, embedding, fts5, substring, hybrid), min_score parameter; fallback logic with FTS failure tolerance, hybrid fusion, per-hit backend labeling.
Semantic Search Tests
tests/embeddings/test_*.py
Core embedder tests (content hashing, registry), mock embedder determinism, real model integration (ST-MPNet/MiniLM/FastEmbed), fusion strategy ranking, storage persistence (put/search), query caching, and end-to-end search ranking.

Security Hardening & Defensive Logic

Layer / File(s) Summary
Bundle Path Safety
src/vouch/bundle.py
_unsafe_name_reason classifier and _safe_member_path resolver reject empty, absolute, NUL, and ..-traversal paths; export_check, import_check, import_apply now validate all tar members and refuse extraction outside KB root (CVE-2007-4559).
File Reading & Symlink Defense
src/vouch/storage.py, tests
read_under_root with path resolution, root-boundary checks, O_NOFOLLOW, and stat-based regular-file validation prevent symlink/TOCTOU escapes during source file ingestion.
Approval Collision Prevention
src/vouch/proposals.py
_ensure_no_existing_artifact() guard in approve() checks for pre-existing artifacts before writing, preventing overwrites during crashes/retries.
Verification Resilience
src/vouch/verify.py
verify_source() catches OSError on stored-content reads and gracefully marks sources unverified instead of aborting the sweep.
Security Tests
tests/test_bundle.py, tests/test_jsonl_server.py, tests/test_storage.py, tests/test_verify.py
Path traversal/absolute-path rejection, symlink escape prevention, approval overwrite blocking, and source verification resilience.

CLI & Operational Improvements

Layer / File(s) Summary
Centralized Error Handling
src/vouch/cli.py
_cli_errors() context manager catches ArtifactNotFoundError, ValueError, ProposalError, LifecycleError and re-raises as click.ClickException; all CLI commands updated to use it for uniform clean error output without tracebacks.
Crystallize Exit Logic
src/vouch/cli.py
session crystallize counts approved vs failed, exits(1) with error when all fail, emits warning on partial failures; _whoami() prefers VOUCH_AGENT, then VOUCH_USER.
CLI Error Tests
tests/test_cli.py
Regression tests verify domain errors (already-decided proposal, empty validation fields, unknown sources) convert to clean Error: ... output.

Comprehensive Specification & Documentation

Layer / File(s) Summary
Protocol Specification with Versioning
SPEC.md, spec/2026-05-21/SPEC.md, spec/2026-05-21/methods.md, spec/2026-05-21/review-gate.md, spec/2026-05-21/transports.md, spec/2026-05-21/retrieval.md, spec/2026-05-21/audit-vocabulary.md
Comprehensive dated specification (2026-05-21) documenting .vouch/ layout, object model, validation rules, review-gate state machine, kb.* methods, MCP/JSONL transports, audit vocabulary, and retrieval semantics with forward-compatibility guidance.
Example Session & Demo
docs/example-session.md, docs/demo.tape, docs/superpowers/specs/2026-05-20-semantic-search-design.md
End-to-end walkthrough with command examples and real outputs, VHS automation script, and design specification for embedding-first search architecture.
Schema Tooling & README Updates
scripts/gen_schemas.py, schemas/README.md, README.md
Schema regeneration script (deterministic JSON from Pydantic models), updated README with banner/suitability guidance/demo GIF, and "What ships today" feature matrix.
Vouch Knowledge Base Initialization
.vouch/ directory
Populates vouch's own KB with claim about review-gated workflow, config for human approval/retrieval backends, and seed sources documenting the MCP/JSONL server and README.

Supporting Changes

Layer / File(s) Summary
Project Configuration
pyproject.toml
Added optional dependency extras: embeddings (sentence-transformers, numpy, sqlite-vec), embeddings-fast (fastembed), rerank (optional reranker); pytest integration marker and mypy overrides for optional libraries.
Type Safety
src/vouch/context.py
ContextItemKind type alias for context item kinds with explicit cast() to improve type checking.
Session Persistence
src/vouch/sessions.py
session_end now calls store.update_session() instead of direct filesystem writes.
Changelog
CHANGELOG.md
Documented vouch crystallize exit code fix and bundle import path-safety hardening.

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~60 minutes

This PR introduces substantial new functionality (embedding-based semantic search with multiple adapters, SQLite vector storage, hybrid fusion), significant security hardening (path safety, TOCTOU defense, approval guards), comprehensive specification documentation, and extensive test coverage across multiple domains. While individual layers are well-isolated and changes follow consistent patterns, the breadth of integration points (storage hooks, index db, server routing, CLI error handling), semantic density (embedding math, search fusion, state machine logic), and variety of file changes (Python source, YAML specs, markdown docs) demand careful reasoning across multiple functional areas and architectures.


Possibly related issues

  • #56 (crystallize exit code bug): This PR directly addresses the reported issue by updating session crystallize in src/vouch/cli.py to count failures/approved proposals and exit non-zero only when all approvals fail (otherwise warn on partial failures).
  • #35 (semantic search feature request): This PR implements the full semantic retrieval stack—embedding adapters, caching, index storage, server/JSONL backend routing, and tests—completing the proposed embedding-first search architecture.

Possibly related PRs

  • vouchdev/vouch#34: Modifies src/vouch/bundle.py validation for imported bundle contents; main PR adds path-safety checks alongside stricter content validation.
  • vouchdev/vouch#27: Adds the same _ensure_no_existing_artifact() guard in src/vouch/proposals.py to prevent approval overwrites.
  • vouchdev/vouch#37: Implements the same embedding adapter registry and modules (src/vouch/embeddings/base.py, st_mpnet.py, __init__.py) as the foundation.

🐰 A rabbit hops through semantic forests so fine,
Embedding each thought with a vector aligned,
Security guards block the path-traversal way,
While clean CLI errors make the humans' day,
Spec pages etched in numbered stone,
A knowledge base that's now its own! 🌿✨

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
⚔️ Resolve merge conflicts
  • Resolve merge conflict in branch fix/57-crystallize-exit-nonzero-on-failure

@Tet-9 Tet-9 closed this May 22, 2026

@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: 17

🤖 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 `@CHANGELOG.md`:
- Around line 9-18: There are two duplicate "### Fixed" headers in the
Unreleased section; consolidate them into a single "### Fixed" header and move
both bullet points under that one header so the bullets for "`vouch crystallize`
now exits..." and "Bundle import rejects tar members whose path escapes
`kb_dir`..." appear together under a single "### Fixed" section, removing the
extra header so the changelog conforms to MD024.

In `@docs/superpowers/specs/2026-05-20-semantic-search-design.md`:
- Around line 33-47: The fenced code block that lists files under
"src/vouch/embeddings/" is missing a language tag, triggering MD040; update the
opening fence from ``` to a labeled fence such as ```text (or ```bash) for that
block so the linter accepts it—find the block that begins with the line
"src/vouch/embeddings/" and change its opening fence accordingly while leaving
the file list (__init__.py, base.py, st_mpnet.py, etc.) unchanged.

In `@README.md`:
- Around line 233-249: The README currently contradicts itself about embeddings:
update the "Retrieval" table row and the later "Status" section so they say the
same thing (e.g., clarify that semantic embedding backends are optional and
available only behind install extras or that vector embeddings are not included
in the base implementation), by editing the "Retrieval" bullet in the "What
ships today" table and the "Status" section text to use identical wording about
availability, prerequisites, and which models (e.g., all-mpnet-base-v2,
MiniLM-L6, fastembed-BGE) are optional extras.

In `@spec/2026-05-21/methods.md`:
- Line 19: Links in spec/2026-05-21/methods.md use "../schemas/..." which
resolves to spec/schemas instead of the repository-level schemas; update the
references by replacing "../schemas/" with "../../schemas/" (e.g., the link on
line containing "**Result:**
[Capabilities](../schemas/capabilities.schema.json)" and the similar link around
line 51) so they point to the repo root schemas folder.
- Around line 201-205: Update the documented result shape for kb.crystallize to
match the CLI contract: add a failures field (array of failure objects or IDs
describing which claims failed and why) and an approval indicator (e.g.,
approved: boolean or outcome: "approved"|"rejected") alongside the existing
page_id and claims_promoted; ensure the spec references the exact symbols used
by the CLI (kb.crystallize, failures, approved/outcome) so consumers and the CLI
agree on the contract.

In `@spec/2026-05-21/retrieval.md`:
- Around line 5-7: The spec text incorrectly says embeddings are “not yet”
supported; update the retrieval spec sections referencing FTS5 and embeddings
(the paragraphs mentioning FTS5 as the only backend and the block around lines
describing embeddings) to state that vector embeddings are now implemented as an
optional, additional retrieval backend that complements — not replaces — FTS5,
and adjust any example/behavior notes and test pointers in the same sections to
reflect current implementation and test coverage; search for the phrases "FTS5
(SQLite full-text) as the only retrieval backend" and "embeddings" to locate and
edit the relevant paragraphs.
- Line 117: Update the Markdown link target in the line "decision. Making this
opt-in in 0.1 (see [ROADMAP.md](../ROADMAP.md))" so it points to the
repository-root ROADMAP; replace the incorrect "../ROADMAP.md" with the correct
"../../ROADMAP.md" (i.e., change the link target string "../ROADMAP.md" to
"../../ROADMAP.md").

In `@spec/2026-05-21/review-gate.md`:
- Around line 135-138: Update the spec text for kb.approve to match tested
behavior: when a durable artifact already exists for a proposal, kb.approve MUST
refuse to overwrite the existing artifact, must NOT move the proposal into
decided/, and must leave the proposal state as PENDING (i.e., do not perform the
durable write nor duplicate it). Update the sentence referencing "If the durable
artifact was written but the proposal not yet moved to `decided/`" to state the
rejection/leave-PENDING semantics for kb.approve and reference the durable
artifact, `decided/`, and PENDING state explicitly.
- Line 14: The fenced code block using triple backticks in the markdown lacks a
language tag and triggers MD040; update that fence to include an explicit
language identifier (for example change ``` to ```text or another appropriate
language) so the markdown linter recognizes the block language.

In `@spec/2026-05-21/SPEC.md`:
- Line 35: Several fenced code blocks in SPEC.md are missing a language tag
which triggers markdownlint MD040; update the triple-backtick fences that
currently read "```" for the diagram/tree listing examples to include an
explicit language (use "```text") so each of the three diagram/treelist blocks
(the fenced blocks containing the ASCII diagrams/tree listings) are changed to
use "```text" instead of plain "```".
- Around line 7-23: The dated SPEC.md contains relative links that are written
as if the file is at repo root; update each broken link in
spec/2026-05-21/SPEC.md (and the other ranges noted: 200-201, 252-253, 260-261,
318-319, 334-335, 365-366, 426) to be correct from the spec/2026-05-21/
directory by either prefixing with the proper relative path (e.g., ../ for
repo-root targets) or converting to root-absolute paths (starting with "/") so
references like the current "[spec/2026-05-21/SPEC.md]" and other in-file links
resolve on GitHub when viewed from the dated spec folder.

In `@spec/2026-05-21/transports.md`:
- Around line 101-103: The sentence conflates two rules about response ordering;
separate them into (1) a requirement that each response echo the same request
`id` (i.e., responses MUST include the request `id` they correspond to) and (2)
a distinct rule that if a server requires in-order processing, responses MUST be
emitted in the same order as request arrival (i.e., when the server processes
requests strictly in arrival order, responses MUST preserve that order); update
the text around "Server response order MUST match the request `id`" to split
these two normative statements and use explicit MUST language for both the
id-echoing requirement and the in-order-emission requirement.

In `@src/vouch/embeddings/cache.py`:
- Around line 15-16: The cache key currently only hashes the raw query in
_query_key which can collide across different embedding models/dimensions;
update _query_key to incorporate a stable embedder identity (e.g., model name,
backend id, and/or dimension) into the hash input so keys are namespaced by
embedder; likewise update the other cache key generators referenced around lines
19-21 and 46-47 to include the same embedder identifier so vectors produced by
different EmbeddingProvider instances cannot collide or produce shape
mismatches. Use the embedder's unique identifier (method/property from your
embedder/EmbeddingProvider) rather than mutable runtime state to construct the
key.

In `@src/vouch/index_db.py`:
- Around line 262-285: The cache keyed by id(conn) in _sqlite_vec_loaded can
falsely hit when object IDs are reused; change the cache to a weak-reference set
of connection objects (use weakref.WeakSet) and update _load_sqlite_vec to check
membership with "if conn in _sqlite_vec_loaded" and add the actual conn object
after successful sqlite_vec.load(conn); also replace the set[int] declaration
with a WeakSet[sqlite3.Connection] and import weakref so the cache automatically
drops closed/garbage-collected connections and avoids stale hits while keeping
the existing enable_load_extension/
sqlite_vec.load/conn.enable_load_extension(False) logic intact.

In `@src/vouch/sessions.py`:
- Around line 44-47: The current assignment to sess.proposal_ids includes
proposals in any status; change the comprehension that builds sess.proposal_ids
(the call to store.list_proposals() and the filter p.session_id == sess.id) to
also restrict to only pending proposals at session end (e.g., p.status ==
ProposalStatus.PENDING or p.status in {ProposalStatus.PENDING,
ProposalStatus.OPEN} depending on your enum names) before sorting, then call
store.update_session(sess) as before; ensure you reference the ProposalStatus
enum or the model's status values that the codebase uses so only unresolved
proposals are recorded.

In `@src/vouch/storage.py`:
- Around line 119-148: The read_under_root function currently unguardedly uses
os.O_NOFOLLOW which is not present on native Windows; update read_under_root to
check hasattr(os, "O_NOFOLLOW") before OR-ing that flag into os.open and handle
the Windows case explicitly: if O_NOFOLLOW exists keep the current
open+fstat+S_ISREG logic, otherwise either implement a Windows-safe alternative
(e.g. open with os.open without O_NOFOLLOW and perform an equivalent atomic
check if available) or raise a clear, specific ValueError/NotImplementedError
indicating the operation is not supported on native Windows; reference
read_under_root, resolved, and the os.open(...) call so the change is applied
around that open/fstat/close flow.

In `@tests/embeddings/test_search.py`:
- Around line 24-28: The autouse fixture _use_mock_embedder mutates the global
embedder registry by calling register(DEFAULT_MODEL_NAME, ...) without restoring
it; change the fixture to snapshot the current registry entry for
DEFAULT_MODEL_NAME (or the whole registry if needed) before calling register,
yield to run the test, and then restore the original registry state in a
finally/teardown step so register and DEFAULT_MODEL_NAME return to their prior
values; reference the _use_mock_embedder fixture, register function and
DEFAULT_MODEL_NAME symbol to locate where to save and restore the registry.
🪄 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: 51d860d2-67ad-4f45-bf48-ce613a1fc20c

📥 Commits

Reviewing files that changed from the base of the PR and between 96028f5 and ef28504.

⛔ Files ignored due to path filters (2)
  • docs/banner.svg is excluded by !**/*.svg
  • docs/demo.gif is excluded by !**/*.gif
📒 Files selected for processing (57)
  • .vouch/.gitignore
  • .vouch/audit.log.jsonl
  • .vouch/claims/vouch-uses-a-review-gated-proposal-workflow-agents-propose-c.yaml
  • .vouch/config.yaml
  • .vouch/decided/20260521-055206-7d6d92d6.yaml
  • .vouch/sources/06d8519f8dcf4149d23c8a48984541b2e9365ec364e7e58192e28ed149a2c47c/content
  • .vouch/sources/06d8519f8dcf4149d23c8a48984541b2e9365ec364e7e58192e28ed149a2c47c/meta.yaml
  • .vouch/sources/67478e72acfb8fac3a059143e95c95f5cc6f7e8d4dccc05fbcea8dbccb8a4eba/content
  • .vouch/sources/67478e72acfb8fac3a059143e95c95f5cc6f7e8d4dccc05fbcea8dbccb8a4eba/meta.yaml
  • CHANGELOG.md
  • README.md
  • SPEC.md
  • docs/demo.tape
  • docs/example-session.md
  • docs/superpowers/plans/2026-05-20-semantic-search.md
  • docs/superpowers/specs/2026-05-20-semantic-search-design.md
  • pyproject.toml
  • schemas/README.md
  • scripts/gen_schemas.py
  • spec/2026-05-21/README.md
  • spec/2026-05-21/SPEC.md
  • spec/2026-05-21/audit-vocabulary.md
  • spec/2026-05-21/methods.md
  • spec/2026-05-21/retrieval.md
  • spec/2026-05-21/review-gate.md
  • spec/2026-05-21/transports.md
  • spec/README.md
  • src/vouch/bundle.py
  • src/vouch/cli.py
  • src/vouch/context.py
  • src/vouch/embeddings/__init__.py
  • src/vouch/embeddings/base.py
  • src/vouch/embeddings/cache.py
  • src/vouch/embeddings/fastembed_bge.py
  • src/vouch/embeddings/fusion.py
  • src/vouch/embeddings/st_minilm.py
  • src/vouch/embeddings/st_mpnet.py
  • src/vouch/index_db.py
  • src/vouch/jsonl_server.py
  • src/vouch/proposals.py
  • src/vouch/server.py
  • src/vouch/sessions.py
  • src/vouch/storage.py
  • src/vouch/verify.py
  • tests/embeddings/__init__.py
  • tests/embeddings/_fakes.py
  • tests/embeddings/conftest.py
  • tests/embeddings/test_core.py
  • tests/embeddings/test_fusion.py
  • tests/embeddings/test_integration.py
  • tests/embeddings/test_search.py
  • tests/embeddings/test_storage.py
  • tests/test_bundle.py
  • tests/test_cli.py
  • tests/test_jsonl_server.py
  • tests/test_storage.py
  • tests/test_verify.py

Comment thread CHANGELOG.md
Comment on lines +9 to +18
### Fixed
- `vouch crystallize` now exits with code 1 and prints an error when all proposals fail to approve, and prints a warning on partial failures.

### Fixed
- Bundle import rejects tar members whose path escapes `kb_dir`
(CVE-2007-4559, #9). Previously a crafted `.tar.gz` with a member
named `../../evil.txt` could write outside `.vouch/`; the manifest
allow-list did not prevent this because the manifest lives inside
the same tarball. `import_apply`, `import_check`, and `export_check`
now validate every member path and raise on unsafe names.

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Consolidate duplicate "### Fixed" sections.

The Unreleased section contains two separate "### Fixed" headers (lines 9 and 12), which violates changelog formatting conventions and triggers the markdownlint MD024 rule. Merge these into a single "### Fixed" section with both bullet points.

📝 Proposed fix
 ## [Unreleased]
 
 ### Fixed
 - `vouch crystallize` now exits with code 1 and prints an error when all proposals fail to approve, and prints a warning on partial failures.
-
-### Fixed
 - Bundle import rejects tar members whose path escapes `kb_dir`
   (CVE-2007-4559, `#9`). Previously a crafted `.tar.gz` with a member
   named `../../evil.txt` could write outside `.vouch/`; the manifest
🧰 Tools
🪛 markdownlint-cli2 (0.22.1)

[warning] 12-12: Multiple headings with the same content

(MD024, no-duplicate-heading)

🤖 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 9 - 18, There are two duplicate "### Fixed"
headers in the Unreleased section; consolidate them into a single "### Fixed"
header and move both bullet points under that one header so the bullets for
"`vouch crystallize` now exits..." and "Bundle import rejects tar members whose
path escapes `kb_dir`..." appear together under a single "### Fixed" section,
removing the extra header so the changelog conforms to MD024.

Comment on lines +33 to +47
```
src/vouch/embeddings/
__init__.py # public API: encode, search, register
base.py # Embedder ABC + adapter registry
st_mpnet.py # default impl (sentence-transformers all-mpnet-base-v2)
st_minilm.py # alternative impl
fastembed_bge.py # alternative impl (no-torch path via fastembed)
cache.py # query embedding LRU + content-hash skip cache
rerank.py # cross-encoder reranker (ms-marco-MiniLM-L6-v2)
hyde.py # Hypothetical Document Embedding query expansion
dedup.py # cosine-threshold duplicate detection at ingest
fusion.py # RRF, weighted-sum, normalized-cosine fusion strategies
eval.py # recall@k / MRR / nDCG harness
migration.py # model-identity check + backfill orchestration
```

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Add a language tag to the fenced code block.

This block is missing a fence language (MD040), which can trip markdown lint in CI.

Suggested fix
-```
+```text
 src/vouch/embeddings/
   __init__.py                # public API: encode, search, register
   base.py                    # Embedder ABC + adapter registry
@@
   eval.py                    # recall@k / MRR / nDCG harness
   migration.py               # model-identity check + backfill orchestration
</details>

<details>
<summary>🧰 Tools</summary>

<details>
<summary>🪛 markdownlint-cli2 (0.22.1)</summary>

[warning] 33-33: Fenced code blocks should have a language specified

(MD040, fenced-code-language)

</details>

</details>

<details>
<summary>🤖 Prompt for AI Agents</summary>

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

In @docs/superpowers/specs/2026-05-20-semantic-search-design.md around lines 33

  • 47, The fenced code block that lists files under "src/vouch/embeddings/" is
    missing a language tag, triggering MD040; update the opening fence from to a labeled fence such astext (or ```bash) for that block so the linter accepts
    it—find the block that begins with the line "src/vouch/embeddings/" and change
    its opening fence accordingly while leaving the file list (init.py, base.py,
    st_mpnet.py, etc.) unchanged.

</details>

<!-- fingerprinting:phantom:poseidon:hawk -->

<!-- This is an auto-generated comment by CodeRabbit -->

Comment thread README.md
Comment on lines +233 to +249
## What ships today

| Area | Current support |
|------|-----------------|
| Knowledge base | `.vouch/` folder, YAML claims/entities/relations/evidence/sessions, markdown pages with frontmatter, JSONL audit log, content-addressed sources |
| CLI | `init`, `discover`, `capabilities`, `status`, `lint`, `doctor`, `pending`, `show`, `approve`, `reject`, `propose-{claim,page,entity,relation}`, `source add`, `source verify`, `supersede`, `contradict`, `archive`, `confirm`, `cite`, `session {start,end}`, `crystallize`, `search`, `context`, `index`, `audit`, `export`, `export-check`, `import-check`, `import-apply`, `serve` |
| Tool servers | MCP over stdio + JSONL over stdin/stdout, same `kb.*` surface across both transports, capabilities + knowledge-capability descriptor |
| Schemas | 13 JSON Schemas (Draft 2020-12) generated from pydantic in [schemas/](schemas/), plus hand-maintained `bundle.manifest` and `jsonl-envelope` schemas |
| Write safety | review-gated writes via [proposed/](spec/review-gate.md), `dry_run:true` previews, host trust required for `approve`/`reject`, atomic exclusive-create storage, path-traversal blocked on source intake and bundle import |
| Retrieval | SQLite FTS5 + substring fallback; optional semantic backends (`all-mpnet-base-v2`, `MiniLM-L6`, fastembed-BGE) behind install extras; context packs with citations + quality gate |
| Lifecycle | `supersede`, `contradict`, `archive`, `confirm`, `cite` — direct mutations, all audited |
| Portability | tar.gz bundles with per-file sha256 `manifest.json`, `export-check`, `import-check`, `import-apply` with skip/overwrite/fail conflict modes |
| Audit | append-only `audit.log.jsonl`, per-event actor (`VOUCH_AGENT`), object ids, dry-run flag, reversible flag |
| Adapters | Claude Code wiring documented via `.mcp.json` + `VOUCH_AGENT` env; per-runtime adapter templates not yet shipped |
| Validation | pytest suite (storage, FTS5, audit, source-verify, review-gate, bundle, JSONL), ruff + mypy gates, GitHub CI |
| Specification | dated snapshots under [spec/](spec/), JSON Schemas in [schemas/](schemas/), generator script at [scripts/gen_schemas.py](scripts/gen_schemas.py) |

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Resolve README contradiction on embeddings availability.

Line 242 says semantic embedding backends ship today, but the later Status section still says vector embeddings are not in this implementation. Please reconcile those statements so users get a single, accurate capability picture.

🤖 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 `@README.md` around lines 233 - 249, The README currently contradicts itself
about embeddings: update the "Retrieval" table row and the later "Status"
section so they say the same thing (e.g., clarify that semantic embedding
backends are optional and available only behind install extras or that vector
embeddings are not included in the base implementation), by editing the
"Retrieval" bullet in the "What ships today" table and the "Status" section text
to use identical wording about availability, prerequisites, and which models
(e.g., all-mpnet-base-v2, MiniLM-L6, fastembed-BGE) are optional extras.

### `kb.capabilities`

**Params:** none.
**Result:** [Capabilities](../schemas/capabilities.schema.json).

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Fix broken relative links to schema files.

From spec/2026-05-21/methods.md, ../schemas/... resolves to spec/schemas/... instead of repository schemas/....

Suggested fix
-**Result:** [Capabilities](../schemas/capabilities.schema.json).
+**Result:** [Capabilities](../../schemas/capabilities.schema.json).
@@
-**Result:** [ContextPack](../schemas/context-pack.schema.json).
+**Result:** [ContextPack](../../schemas/context-pack.schema.json).

Also applies to: 51-51

🤖 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 `@spec/2026-05-21/methods.md` at line 19, Links in spec/2026-05-21/methods.md
use "../schemas/..." which resolves to spec/schemas instead of the
repository-level schemas; update the references by replacing "../schemas/" with
"../../schemas/" (e.g., the link on line containing "**Result:**
[Capabilities](../schemas/capabilities.schema.json)" and the similar link around
line 51) so they point to the repo root schemas folder.

Comment on lines +201 to +205
### `kb.crystallize`

**Params:** `{ "session_id": str, "no_page": bool? }`.
**Result:** `{ "page_id": str?, "claims_promoted": int }`.

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

kb.crystallize result shape is missing failure/approval fields used by CLI behavior.

The documented result omits fields that the CLI now relies on (failures and approved outcome), so this spec snapshot no longer matches the actual contract.

Suggested fix
-**Result:** `{ "page_id": str?, "claims_promoted": int }`.
+**Result:** `{ "page_id": str?, "claims_promoted": int, "approved": [str], "failures": [{"proposal_id": str, "error": str}] }`.
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
### `kb.crystallize`
**Params:** `{ "session_id": str, "no_page": bool? }`.
**Result:** `{ "page_id": str?, "claims_promoted": int }`.
### `kb.crystallize`
**Params:** `{ "session_id": str, "no_page": bool? }`.
**Result:** `{ "page_id": str?, "claims_promoted": int, "approved": [str], "failures": [{"proposal_id": str, "error": str}] }`.
🤖 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 `@spec/2026-05-21/methods.md` around lines 201 - 205, Update the documented
result shape for kb.crystallize to match the CLI contract: add a failures field
(array of failure objects or IDs describing which claims failed and why) and an
approval indicator (e.g., approved: boolean or outcome: "approved"|"rejected")
alongside the existing page_id and claims_promoted; ensure the spec references
the exact symbols used by the CLI (kb.crystallize, failures, approved/outcome)
so consumers and the CLI agree on the contract.

Comment on lines +15 to +16
def _query_key(query: str) -> str:
return hashlib.sha256(query.encode("utf-8")).hexdigest()

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major | 🏗️ Heavy lift

Namespace the cache key by embedder identity.

Line 15 currently keys only on query, so the same text can return stale vectors across different models/backends (or dimensions). That can silently degrade ranking or raise shape errors downstream.

Suggested direction
-def _query_key(query: str) -> str:
-    return hashlib.sha256(query.encode("utf-8")).hexdigest()
+def _query_key(query: str, *, namespace: str) -> str:
+    material = f"{namespace}\n{query}".encode("utf-8")
+    return hashlib.sha256(material).hexdigest()

 def cache_query_vec(
-    kb_dir: Path, *, query: str, vec: np.ndarray, max_entries: int = 1024,
+    kb_dir: Path, *, query: str, vec: np.ndarray, namespace: str, max_entries: int = 1024,
 ) -> None:
-    h = _query_key(query)
+    h = _query_key(query, namespace=namespace)

 def lookup_query_vec(kb_dir: Path, *, query: str) -> np.ndarray | None:
-    h = _query_key(query)
+    h = _query_key(query, namespace=namespace)

Also applies to: 19-21, 46-47

🤖 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/embeddings/cache.py` around lines 15 - 16, The cache key currently
only hashes the raw query in _query_key which can collide across different
embedding models/dimensions; update _query_key to incorporate a stable embedder
identity (e.g., model name, backend id, and/or dimension) into the hash input so
keys are namespaced by embedder; likewise update the other cache key generators
referenced around lines 19-21 and 46-47 to include the same embedder identifier
so vectors produced by different EmbeddingProvider instances cannot collide or
produce shape mismatches. Use the embedder's unique identifier (method/property
from your embedder/EmbeddingProvider) rather than mutable runtime state to
construct the key.

Comment thread src/vouch/index_db.py
Comment on lines +262 to +285
_sqlite_vec_loaded: set[int] = set()


def _load_sqlite_vec(conn: sqlite3.Connection) -> bool:
"""Best-effort load of the sqlite-vec extension."""
if id(conn) in _sqlite_vec_loaded:
return True
try:
conn.enable_load_extension(True)
except (AttributeError, sqlite3.OperationalError):
return False
try:
import sqlite_vec # type: ignore[import-not-found]
except ImportError:
return False
try:
sqlite_vec.load(conn)
except sqlite3.OperationalError:
return False
finally:
with suppress(sqlite3.OperationalError):
conn.enable_load_extension(False)
_sqlite_vec_loaded.add(id(conn))
return True

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Cache keyed by id(conn) can produce stale hits after connection close/reopen.

Python reuses object IDs after garbage collection, so a newly opened connection may receive the same id() as a previously closed one. The cache would then incorrectly indicate sqlite_vec is loaded when it isn't (extensions must be loaded per-connection).

Consider using a WeakSet keyed by connection object instead, or simply remove caching since extension loading is cheap and connections are typically short-lived in this codebase.

Proposed fix using WeakSet
-_sqlite_vec_loaded: set[int] = set()
+import weakref
+_sqlite_vec_loaded: weakref.WeakSet[sqlite3.Connection] = weakref.WeakSet()


 def _load_sqlite_vec(conn: sqlite3.Connection) -> bool:
     """Best-effort load of the sqlite-vec extension."""
-    if id(conn) in _sqlite_vec_loaded:
+    if conn in _sqlite_vec_loaded:
         return True
     try:
         conn.enable_load_extension(True)
     except (AttributeError, sqlite3.OperationalError):
         return False
     try:
         import sqlite_vec  # type: ignore[import-not-found]
     except ImportError:
         return False
     try:
         sqlite_vec.load(conn)
     except sqlite3.OperationalError:
         return False
     finally:
         with suppress(sqlite3.OperationalError):
             conn.enable_load_extension(False)
-    _sqlite_vec_loaded.add(id(conn))
+    _sqlite_vec_loaded.add(conn)
     return True
🤖 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/index_db.py` around lines 262 - 285, The cache keyed by id(conn) in
_sqlite_vec_loaded can falsely hit when object IDs are reused; change the cache
to a weak-reference set of connection objects (use weakref.WeakSet) and update
_load_sqlite_vec to check membership with "if conn in _sqlite_vec_loaded" and
add the actual conn object after successful sqlite_vec.load(conn); also replace
the set[int] declaration with a WeakSet[sqlite3.Connection] and import weakref
so the cache automatically drops closed/garbage-collected connections and avoids
stale hits while keeping the existing enable_load_extension/
sqlite_vec.load/conn.enable_load_extension(False) logic intact.

Comment thread src/vouch/sessions.py
Comment on lines 44 to +47
sess.proposal_ids = sorted({
p.id for p in store.list_proposals() if p.session_id == sess.id
})
path = store._session_path(sess.id)
path.write_text(_yaml_dump(sess.model_dump(mode="json")))
store.update_session(sess)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Filter proposal_ids to pending proposals at session end.

Line 45 currently scans all statuses, so proposal_ids includes already resolved proposals. That inflates session-end counts and contradicts the expected semantics for issue #57.

Proposed fix
-    sess.proposal_ids = sorted({
-        p.id for p in store.list_proposals() if p.session_id == sess.id
-    })
+    sess.proposal_ids = sorted({
+        p.id
+        for p in store.list_proposals(ProposalStatus.PENDING)
+        if p.session_id == sess.id
+    })
🤖 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/sessions.py` around lines 44 - 47, The current assignment to
sess.proposal_ids includes proposals in any status; change the comprehension
that builds sess.proposal_ids (the call to store.list_proposals() and the filter
p.session_id == sess.id) to also restrict to only pending proposals at session
end (e.g., p.status == ProposalStatus.PENDING or p.status in
{ProposalStatus.PENDING, ProposalStatus.OPEN} depending on your enum names)
before sorting, then call store.update_session(sess) as before; ensure you
reference the ProposalStatus enum or the model's status values that the codebase
uses so only unresolved proposals are recorded.

Comment thread src/vouch/storage.py
Comment on lines +119 to +148
def read_under_root(self, path: str | Path) -> tuple[Path, bytes]:
# Guard against arbitrary-file-read primitives exposed by the MCP /
# JSONL `register_source_from_path` entrypoints, and against a TOCTOU
# race between the containment check and the read: an attacker who
# can swap the resolved name for a symlink after Path.resolve() has
# validated it could otherwise still exfiltrate an out-of-root file.
#
# Path.resolve() chases any pre-existing symlinks first (so legitimate
# in-root symlinks still work, then their target is the thing checked
# for containment). O_NOFOLLOW on the open then rejects a fresh
# symlink swapped into the resolved name after the containment check.
# fstat + S_ISREG rejects directories / special files atomically.
resolved = Path(path).resolve()
if not resolved.is_relative_to(self.root):
raise ValueError(
f"path must be inside project root ({self.root}): {resolved}"
)
try:
fd = os.open(resolved, os.O_RDONLY | os.O_NOFOLLOW)
except OSError as e:
raise ValueError(f"cannot read {resolved}: {e}") from e
try:
if not stat.S_ISREG(os.fstat(fd).st_mode):
os.close(fd)
raise ValueError(f"not a regular file: {resolved}")
except OSError:
os.close(fd)
raise
with os.fdopen(fd, "rb") as f:
return resolved, f.read()

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major | 💤 Low value

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Check if there's any Windows compatibility consideration in the codebase
rg -l -i "windows|win32|platform" --type=py

Repository: vouchdev/vouch

Length of output: 1935


🌐 Web query:

Python os.O_NOFOLLOW Windows availability

💡 Result:

Python’s os.O_NOFOLLOW is not available/portable on Windows: it’s a Unix feature, and the Python os module only provides it when the underlying C library defines it [1]. In practice, on Windows you should not expect os.O_NOFOLLOW to exist (i.e., attribute may be missing), and any code using it must be guarded with a feature check or use a Windows-specific approach [2][1]. How to check in code: - On Windows, test whether the attribute exists: hasattr(os, "O_NOFOLLOW") before using it [2]. - The official os documentation notes that the availability of these O_* constants depends on what the platform C library defines (“The above constants are extensions and not present if they are not defined by the C library”) [1]. Bottom line (for 2026-05-22): on native Windows (not WSL), treat os.O_NOFOLLOW as unsupported/absent and don’t rely on it for symlink “no follow” behavior [1].

Citations:


Make read_under_root handle native Windows where os.O_NOFOLLOW is missing

os.O_NOFOLLOW is a Unix-only constant; on native Windows it may be undefined, so os.open(resolved, os.O_RDONLY | os.O_NOFOLLOW) can raise AttributeError and break this security path. Guard with hasattr(os, "O_NOFOLLOW") and either fall back to a Windows-appropriate approach or raise a clear “not supported on Windows” error.

🤖 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/storage.py` around lines 119 - 148, The read_under_root function
currently unguardedly uses os.O_NOFOLLOW which is not present on native Windows;
update read_under_root to check hasattr(os, "O_NOFOLLOW") before OR-ing that
flag into os.open and handle the Windows case explicitly: if O_NOFOLLOW exists
keep the current open+fstat+S_ISREG logic, otherwise either implement a
Windows-safe alternative (e.g. open with os.open without O_NOFOLLOW and perform
an equivalent atomic check if available) or raise a clear, specific
ValueError/NotImplementedError indicating the operation is not supported on
native Windows; reference read_under_root, resolved, and the os.open(...) call
so the change is applied around that open/fstat/close flow.

Comment on lines +24 to +28
@pytest.fixture(autouse=True)
def _use_mock_embedder() -> None:
from vouch.embeddings.base import DEFAULT_MODEL_NAME
register(DEFAULT_MODEL_NAME, lambda: MockEmbedder(dim=8))

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Restore global embedder registry state in the autouse fixture.

Line [27] mutates shared global registry state and never restores it, which can leak mock as the default embedder into later modules and make test outcomes order-dependent.

Proposed fix
 `@pytest.fixture`(autouse=True)
-def _use_mock_embedder() -> None:
-    from vouch.embeddings.base import DEFAULT_MODEL_NAME
+def _use_mock_embedder(request: pytest.FixtureRequest) -> None:
+    from vouch.embeddings.base import DEFAULT_MODEL_NAME, _REGISTRY
+    prev = _REGISTRY.get(DEFAULT_MODEL_NAME)
     register(DEFAULT_MODEL_NAME, lambda: MockEmbedder(dim=8))
+
+    def _restore() -> None:
+        if prev is None:
+            _REGISTRY.pop(DEFAULT_MODEL_NAME, None)
+        else:
+            _REGISTRY[DEFAULT_MODEL_NAME] = prev
+
+    request.addfinalizer(_restore)
🤖 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/embeddings/test_search.py` around lines 24 - 28, The autouse fixture
_use_mock_embedder mutates the global embedder registry by calling
register(DEFAULT_MODEL_NAME, ...) without restoring it; change the fixture to
snapshot the current registry entry for DEFAULT_MODEL_NAME (or the whole
registry if needed) before calling register, yield to run the test, and then
restore the original registry state in a finally/teardown step so register and
DEFAULT_MODEL_NAME return to their prior values; reference the
_use_mock_embedder fixture, register function and DEFAULT_MODEL_NAME symbol to
locate where to save and restore the registry.

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

bug: session_end backfills proposal_ids with all statuses including decided proposals

4 participants