From cdf01f582bcc0d0afbe70ab78bb481e4828a794c Mon Sep 17 00:00:00 2001
From: plind <59729252+plind-junior@users.noreply.github.com>
Date: Mon, 13 Jul 2026 05:45:33 -0400
Subject: [PATCH 01/12] Demo update (#466)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
* feat(transcript): locate raw Claude session files by id
* feat(transcript): parse Claude JSONL into normalized blocks
* feat(transcript): orchestrate load with observation fallback
* feat(transcript): expose kb.session_transcript across all surfaces
* feat(webapp): transcript client types and fetch
* feat(webapp): thinking, diff, and code block renderers
* feat(webapp): tool block with per-tool rendering and diffs
* feat(webapp): sessions tab with full transcript viewer
* feat(transcript): parse Codex rollouts into normalized blocks
* test(webapp): cover degraded render and subagent drill-down
* test(webapp): e2e smoke for the sessions transcript tab
* docs(transcript): session transcript viewer design and plan
* refactor(webapp): merge session transcript into Review, drop Sessions tab
* docs(transcript): note the merge of the viewer into Review
* feat(activity): expose kb.activity audit buckets across all surfaces
one pass over the audit log returning per-day counts (with
proposal/decision breakdowns), an hour-of-week matrix, and actor/event
histograms — the data a dashboard needs that kb.audit's tail-limited
raw events and kb.stats' review aggregates don't cover.
windowed in viewer-local calendar days so the oldest heatmap cell is
never partially counted (days=0 for all-time, negatives rejected).
local bucketing prefers an iana tz name (dst-correct across the year)
and falls back to a clamped fixed utc offset. scope-filtered through
the same viewer context as kb.audit so multi-project kbs don't leak
actor names or activity timing across project boundaries.
registered at all four sites (mcp tool, jsonl handler, capabilities
methods, vouch activity cli) plus trust read methods.
* feat(webapp): dashboard view of kb activity, landing on /
new /dashboard view driven by kb.activity: a 12-month activity
calendar (ember heat ramp themed for dark and light via --heat-* css
vars), last-30-days bars, an hour-of-week heatmap, and top-actor /
event-mix bar lists, with stat tiles fed by kb.status and kb.stats.
the calendar fetches 371 days so its oldest drawn column always sits
inside the server window, sends the browser's iana timezone for
dst-correct bucketing, and degrades cleanly: endpoints that don't
advertise kb.activity get an upgrade hint, empty audit logs get an
empty state, and a malformed stats payload no longer crashes the view.
/ now redirects to the dashboard instead of chat and the tab leads the
sidebar; the e2e smoke flow clicks through to chat accordingly.
* feat(console): add vouch-ui console, demo setup, and web integration
add web-based console with dashboard view of kb activity and session management.
integrate vouch-ui webapp, docker-compose demo environment, and llm-backed
actions (compile + summarize). includes hatch build script and full test coverage.
* fix(review): remove remaining conflict markers
cleaned up merge conflict markers that were preventing the component from loading.
* feat(synthesize): llm answer backend grounded in pages, live in chat
implement the reserved llm=true path of kb.synthesize: retrieval picks
kb pages and approved claims, the deployment-configured compile.llm_cmd
drafts the prose, and code verifies every [id] citation against the
offered sources — invented ids are stripped, and a draft left with no
verifiable citation returns an empty answer instead of a guess. the
wire shape is unchanged plus additive pages and _meta.synthesis_backend
fields.
the console chat now asks with llm: true and falls back to
deterministic claim synthesis when no llm_cmd is configured; page
citations open the page drawer and llm answers carry a badge. cli
mirror: vouch synthesize --llm. the mcp tool gains the llm param; the
jsonl/http surface already forwarded it.
---
CHANGELOG.md | 12 ++
src/vouch/cli.py | 11 +-
src/vouch/llm_draft.py | 17 +++
src/vouch/server.py | 14 +-
src/vouch/synthesize.py | 213 +++++++++++++++++++++++++++--
tests/test_synthesize.py | 91 +++++++++++-
webapp/src/lib/types.ts | 4 +-
webapp/src/views/ChatView.test.tsx | 56 ++++++++
webapp/src/views/ChatView.tsx | 36 ++++-
9 files changed, 427 insertions(+), 27 deletions(-)
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 707ebe2d..d907b836 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -39,6 +39,18 @@ All notable changes to vouch are documented here. Format follows
filtered like `kb.audit`.
- console Dashboard view: 12-month activity calendar, last-30-days bars,
hour-of-week heatmap, top actors and event mix, driven by `kb.activity`.
+- `kb.synthesize` llm backend: `llm=true` drafts the answer with the
+ deployment-configured `compile.llm_cmd`, grounded in retrieved kb pages
+ and approved claims. code still verifies every `[id]` citation against
+ the offered sources — invented ids are stripped, and a draft left with no
+ verifiable citation returns an empty answer rather than a guess. the wire
+ shape is unchanged plus additive `pages` and `_meta.synthesis_backend`
+ fields. cli mirror: `vouch synthesize --llm`; the jsonl/http surface
+ already forwarded the flag.
+- console Chat: llm answers activated — the chat asks `kb.synthesize` with
+ `llm: true` and falls back to deterministic claim synthesis when no
+ `compile.llm_cmd` is configured. page citations open the page drawer, and
+ llm answers carry an `llm` badge next to the confidence grade.
- codex: wired `UserPromptSubmit` to `vouch context-hook` for the first
time, reusing the existing command unmodified — codex's hook
payload/response shape matches claude-code's exactly. (#425)
diff --git a/src/vouch/cli.py b/src/vouch/cli.py
index 5ea1460e..abe36889 100644
--- a/src/vouch/cli.py
+++ b/src/vouch/cli.py
@@ -2642,12 +2642,17 @@ def context_hook() -> None:
@click.argument("query")
@click.option("--depth", default=3, show_default=True, type=int)
@click.option("--max-chars", default=4000, show_default=True, type=int)
-def synthesize(query: str, depth: int, max_chars: int) -> None:
- """Answer a query from approved claims only, with inline citations."""
+@click.option(
+ "--llm", "use_llm", is_flag=True,
+ help="Draft the answer with the configured compile.llm_cmd, grounded in "
+ "pages and approved claims (citations still verified mechanically).",
+)
+def synthesize(query: str, depth: int, max_chars: int, use_llm: bool) -> None:
+ """Answer a query from the KB, with inline citations."""
store = _load_store()
with _cli_errors():
result = synth.synthesize(
- store, query=query, depth=depth, max_chars=max_chars,
+ store, query=query, depth=depth, max_chars=max_chars, llm=use_llm,
)
_emit_json(result)
diff --git a/src/vouch/llm_draft.py b/src/vouch/llm_draft.py
index cba54cf3..3c3f1943 100644
--- a/src/vouch/llm_draft.py
+++ b/src/vouch/llm_draft.py
@@ -56,6 +56,23 @@ def run_llm(
return proc.stdout
+def parse_object(raw: str, *, noun: str = "answer") -> dict[str, Any]:
+ """Parse LLM stdout into a single JSON object.
+
+ The object-shaped sibling of `parse_drafts`: same fence stripping, same
+ LLMDraftError contract, for callers whose LLM returns one object (e.g.
+ synthesize's `{"answer": …, "gaps": […]}`) rather than an array of drafts.
+ """
+ text = _FENCE_RE.sub("", raw.strip()).strip()
+ try:
+ data = json.loads(text)
+ except json.JSONDecodeError as e:
+ raise LLMDraftError(f"{noun} output is not valid JSON: {e}") from e
+ if not isinstance(data, dict):
+ raise LLMDraftError(f"{noun} output must be a single JSON object")
+ return data
+
+
def parse_drafts(raw: str, *, noun: str = "page") -> list[dict[str, Any]]:
"""Parse LLM stdout into a list of draft dicts.
diff --git a/src/vouch/server.py b/src/vouch/server.py
index c395b6e1..7f4bfde5 100644
--- a/src/vouch/server.py
+++ b/src/vouch/server.py
@@ -346,14 +346,20 @@ def kb_synthesize(
query: str,
depth: int = 3,
max_chars: int = 4000,
+ llm: bool = False,
) -> dict[str, Any]:
- """Answer a query from approved claims only, with inline `[claim_id]`
- citations, an explicit gaps block, and a synthesis_confidence grade.
+ """Answer a query from the review-gated KB, with inline `[id]` citations,
+ an explicit gaps block, and a synthesis_confidence grade.
Unlike `kb_context` (a ranked list), this returns prose where every
- sentence is traceable to an approved claim.
+ sentence is traceable to a source. Deterministic by default (approved
+ claims only); `llm=True` drafts the answer with the deployment-configured
+ LLM (compile.llm_cmd) grounded in pages and approved claims — citations
+ are still verified mechanically, and the call is synchronous.
"""
- return synthesize(_store(), query=query, depth=depth, max_chars=max_chars)
+ return synthesize(
+ _store(), query=query, depth=depth, max_chars=max_chars, llm=llm,
+ )
@mcp.tool()
diff --git a/src/vouch/synthesize.py b/src/vouch/synthesize.py
index a45eee73..20577b85 100644
--- a/src/vouch/synthesize.py
+++ b/src/vouch/synthesize.py
@@ -7,17 +7,25 @@
no claim for in an explicit `gaps` block, and grades its own confidence from
the lifecycle status of the claims it cited.
-The synthesis is deterministic in v1 — there is no LLM in the loop. The
-`llm` flag is reserved so the wire shape is stable when an opt-in generative
-backend lands; passing `llm=True` raises rather than silently degrading.
+The default synthesis is deterministic — no LLM in the loop. `llm=True`
+opts into the generative backend: the deployment-configured LLM command
+(``compile.llm_cmd``, the same one the wiki compiler uses) drafts prose
+grounded in retrieved *pages* and approved claims. The division of labor
+stays llm-wiki's: the LLM writes, code verifies — every ``[id]`` citation
+in the draft must resolve to a source that was actually offered, or it is
+stripped; an answer left with no verifiable citation is returned empty
+rather than uncited. Requesting `llm=True` without a configured command
+raises rather than silently degrading.
"""
from __future__ import annotations
+import re
from typing import Any, Literal
+from . import llm_draft
from .context import build_context_pack
-from .models import Claim, ClaimStatus
+from .models import Claim, ClaimStatus, Page, PageStatus
from .storage import ArtifactNotFoundError, KBStore
Confidence = Literal["high", "medium", "low"]
@@ -71,6 +79,183 @@ def _confidence(statuses: list[ClaimStatus]) -> Confidence:
return "medium"
+# LLM-backend grounding budgets. Pages are the product (llm-wiki): they get
+# the deep budget; claims ground the sentences pages haven't compiled yet.
+_LLM_PAGE_LIMIT = 6
+_LLM_CLAIM_LIMIT = 12
+_LLM_PAGE_CHARS = 4000
+
+# Bracketed tokens in the draft. Anything bracketed that is not an offered
+# source id is stripped — the model must not mint provenance.
+_MARKER_RE = re.compile(r"\s*\[([^\[\]]+)\]")
+
+
+def _llm_grounding(
+ store: KBStore, query: str, depth: int
+) -> tuple[list[Page], list[Claim]]:
+ """Retrieve the pages and approved claims the LLM may cite.
+
+ Retrieval-first via the context pack; when the index surfaces no page for
+ the query, fall back to the most recently updated pages so the chat still
+ checks the wiki on small or sparsely-indexed KBs.
+ """
+ pack = build_context_pack(store, query=query, limit=max(depth * 4, 12))
+ items = pack["items"] if isinstance(pack, dict) else pack.items
+ page_ids: list[str] = []
+ claim_ids: list[str] = []
+ for item in items:
+ kind = item["type"] if isinstance(item, dict) else item.type
+ iid = item["id"] if isinstance(item, dict) else item.id
+ bucket = page_ids if kind == "page" else claim_ids if kind == "claim" else None
+ if bucket is not None and iid not in bucket:
+ bucket.append(iid)
+
+ pages: list[Page] = []
+ for pid in page_ids:
+ if len(pages) >= _LLM_PAGE_LIMIT:
+ break
+ try:
+ page = store.get_page(pid)
+ except ArtifactNotFoundError:
+ continue
+ if page.status != PageStatus.ARCHIVED:
+ pages.append(page)
+ if not pages:
+ live = [p for p in store.list_pages() if p.status != PageStatus.ARCHIVED]
+ live.sort(key=lambda p: p.updated_at, reverse=True)
+ pages = live[:_LLM_PAGE_LIMIT]
+
+ claims: list[Claim] = []
+ for cid in claim_ids:
+ if len(claims) >= _LLM_CLAIM_LIMIT:
+ break
+ try:
+ claims.append(store.get_claim(cid))
+ except ArtifactNotFoundError:
+ continue
+ return pages, claims
+
+
+def _llm_prompt(
+ query: str, pages: list[Page], claims: list[Claim], max_chars: int
+) -> str:
+ lines = [
+ "You answer a question from a review-gated knowledge base.",
+ "Use ONLY the sources below — never outside knowledge.",
+ "After every sentence, cite the id of the supporting source in square "
+ "brackets, e.g. [some-id]. Only ids listed below count as citations.",
+ "If part of the question is not covered by the sources, do not guess — "
+ "name that topic in gaps instead.",
+ f"Keep the answer under {max_chars} characters.",
+ "Output exactly one JSON object and nothing else, no code fence:",
+ '{"answer": "…", "gaps": ["uncovered topic", "…"]}',
+ "",
+ f"Question: {query}",
+ "",
+ "Pages:",
+ ]
+ for page in pages:
+ body = page.body.strip()
+ if len(body) > _LLM_PAGE_CHARS:
+ body = body[:_LLM_PAGE_CHARS] + " …"
+ lines.append(f"[{page.id}] {page.title}\n{body}\n")
+ if not pages:
+ lines.append("(none)")
+ lines.append("Claims:")
+ for claim in claims:
+ lines.append(f"[{claim.id}] ({claim.status.value}) {claim.text}")
+ if not claims:
+ lines.append("(none)")
+ return "\n".join(lines)
+
+
+def _llm_synthesize(
+ store: KBStore, *, query: str, depth: int, max_chars: int
+) -> dict[str, Any]:
+ # The LLM command is deployment config shared with the wiki compiler —
+ # one knob (compile.llm_cmd) turns on every generative feature.
+ from .compile import load_config
+
+ cfg = load_config(store)
+ if not cfg.llm_cmd:
+ raise ValueError(
+ "llm synthesis is not configured — set compile.llm_cmd in "
+ '.vouch/config.yaml, e.g.\ncompile:\n llm_cmd: "claude -p --model sonnet"'
+ )
+
+ pages, claims = _llm_grounding(store, query, depth)
+ if not pages and not claims:
+ return {
+ "query": query,
+ "answer": "",
+ "claims": [],
+ "pages": [],
+ "gaps": _salient_terms(query),
+ "_meta": {
+ "synthesis_confidence": _confidence([]),
+ "synthesis_backend": "llm",
+ },
+ }
+
+ prompt = _llm_prompt(query, pages, claims, max_chars)
+ try:
+ raw = llm_draft.run_llm(
+ cfg.llm_cmd, prompt,
+ timeout_seconds=cfg.timeout_seconds, label="compile.llm_cmd",
+ )
+ data = llm_draft.parse_object(raw, noun="synthesis")
+ except llm_draft.LLMDraftError as e:
+ raise ValueError(str(e)) from e
+
+ answer = str(data.get("answer") or "").strip()
+ gaps = [str(g) for g in data.get("gaps") or [] if str(g).strip()]
+
+ # Code verifies what the model drafted: strip any bracketed token that is
+ # not an offered source id, then truncate to budget on a citation boundary
+ # so no dangling half-citation survives.
+ offered = {p.id for p in pages} | {c.id for c in claims}
+ dropped: list[str] = []
+
+ def _keep(m: re.Match[str]) -> str:
+ if m.group(1) in offered:
+ return m.group(0)
+ dropped.append(m.group(1))
+ return ""
+
+ answer = _MARKER_RE.sub(_keep, answer)
+ answer = re.sub(r" +([.,;:])", r"\1", re.sub(r" {2,}", " ", answer)).strip()
+ if len(answer) > max_chars:
+ cut = answer.rfind("]", 0, max_chars)
+ answer = answer[: cut + 1] if cut > 0 else answer[:max_chars]
+
+ cited = [m.group(1) for m in _MARKER_RE.finditer(answer)]
+ cited = list(dict.fromkeys(cited))
+ if not cited:
+ # an uncited draft is a guess — the KB stays silent instead
+ answer = ""
+ gaps = gaps or _salient_terms(query)
+
+ claim_by_id = {c.id: c for c in claims}
+ cited_claims = [cid for cid in cited if cid in claim_by_id]
+ cited_pages = [cid for cid in cited if cid not in claim_by_id]
+ meta: dict[str, Any] = {
+ "synthesis_confidence": _confidence(
+ [claim_by_id[cid].status for cid in cited_claims]
+ ),
+ "synthesis_backend": "llm",
+ }
+ if dropped:
+ meta["dropped_citations"] = dropped
+ return {
+ "query": query,
+ "answer": answer,
+ "claims": cited_claims,
+ "pages": cited_pages,
+ "gaps": gaps,
+ "_meta": meta,
+ }
+
+
def synthesize(
store: KBStore,
*,
@@ -79,16 +264,18 @@ def synthesize(
max_chars: int = 4000,
llm: bool = False,
) -> dict[str, Any]:
- """Answer `query` from approved claims only, with inline citations.
+ """Answer `query` from the review-gated KB, with inline citations.
Returns a dict with `query`, `answer` (citation-bearing prose, possibly
- empty), `claims` (the cited claim ids), `gaps` (query topics no approved
- claim covered) and `_meta.synthesis_confidence`.
+ empty), `claims` (the cited claim ids), `pages` (cited page ids — always
+ empty on the deterministic path), `gaps` (query topics no source covered)
+ and `_meta.synthesis_confidence`. With `llm=True` the answer is drafted
+ by the deployment-configured LLM grounded in pages and approved claims;
+ every citation is still verified mechanically.
"""
if llm:
- raise ValueError(
- "llm synthesis backend not configured; "
- "deterministic synthesis is the default"
+ return _llm_synthesize(
+ store, query=query, depth=depth, max_chars=max_chars,
)
pack = build_context_pack(store, query=query, limit=depth)
@@ -135,6 +322,10 @@ def synthesize(
"query": query,
"answer": answer,
"claims": cited,
+ "pages": [],
"gaps": gaps,
- "_meta": {"synthesis_confidence": _confidence(statuses)},
+ "_meta": {
+ "synthesis_confidence": _confidence(statuses),
+ "synthesis_backend": "deterministic",
+ },
}
diff --git a/tests/test_synthesize.py b/tests/test_synthesize.py
index 9d406467..f2dec904 100644
--- a/tests/test_synthesize.py
+++ b/tests/test_synthesize.py
@@ -2,6 +2,7 @@
from __future__ import annotations
+import json
import re
from pathlib import Path
@@ -9,7 +10,7 @@
from vouch import capabilities, health, synthesize
from vouch.jsonl_server import HANDLERS, handle_request
-from vouch.models import Claim, ClaimStatus
+from vouch.models import Claim, ClaimStatus, Page, PageStatus
from vouch.storage import KBStore
_CITE = re.compile(r"\[([^\[\]]+)\]")
@@ -99,11 +100,95 @@ def test_confidence_reflects_claim_status(store: KBStore) -> None:
assert contested["_meta"]["synthesis_confidence"] == "low"
-def test_llm_flag_is_reserved(store: KBStore) -> None:
- with pytest.raises(ValueError, match="llm synthesis backend not configured"):
+def test_llm_without_config_raises(store: KBStore) -> None:
+ with pytest.raises(ValueError, match="llm synthesis is not configured"):
synthesize.synthesize(store, query="auth", llm=True)
+# --- the llm backend --------------------------------------------------------
+
+
+def _wire_llm(store: KBStore, tmp_path: Path, payload: object) -> None:
+ """Point compile.llm_cmd at a stub that emits `payload` as JSON."""
+ out = tmp_path / "answer.json"
+ out.write_text(json.dumps(payload), encoding="utf-8")
+ store.config_path.write_text(
+ store.config_path.read_text(encoding="utf-8")
+ + f'\ncompile:\n llm_cmd: "cat {out}"\n',
+ encoding="utf-8",
+ )
+
+
+def _auth_page(store: KBStore) -> str:
+ page = Page(
+ id="auth-overview", title="Auth Overview",
+ body="Access tokens are short-lived JWTs; refresh tokens rotate.",
+ status=PageStatus.ACTIVE,
+ )
+ store.put_page(page)
+ health.rebuild_index(store)
+ return page.id
+
+
+def test_llm_synthesize_cites_pages_and_strips_invented_ids(
+ store: KBStore, tmp_path: Path,
+) -> None:
+ _auth_kb(store)
+ pid = _auth_page(store)
+ _wire_llm(store, tmp_path, {
+ "answer": f"Auth uses short-lived JWTs [{pid}]. "
+ "Refresh tokens rotate on every use [c-auth-2]. "
+ "Billing rounds half-up [invented-id].",
+ "gaps": ["billing"],
+ })
+ result = synthesize.synthesize(store, query="auth tokens", llm=True)
+ assert result["pages"] == [pid]
+ assert result["claims"] == ["c-auth-2"]
+ assert "[invented-id]" not in result["answer"]
+ assert "billing" in result["gaps"]
+ assert result["_meta"]["synthesis_backend"] == "llm"
+ assert result["_meta"]["dropped_citations"] == ["invented-id"]
+ assert result["_meta"]["synthesis_confidence"] == "high"
+
+
+def test_llm_uncited_draft_stays_silent(store: KBStore, tmp_path: Path) -> None:
+ _auth_page(store)
+ _wire_llm(store, tmp_path, {"answer": "Auth is fine, trust me.", "gaps": []})
+ result = synthesize.synthesize(store, query="auth", llm=True)
+ assert result["answer"] == ""
+ assert result["claims"] == []
+ assert result["pages"] == []
+ assert result["gaps"]
+
+
+def test_llm_unusable_output_raises(store: KBStore, tmp_path: Path) -> None:
+ _auth_page(store)
+ store.config_path.write_text(
+ store.config_path.read_text(encoding="utf-8")
+ + '\ncompile:\n llm_cmd: "echo not json"\n',
+ encoding="utf-8",
+ )
+ with pytest.raises(ValueError, match="not valid JSON"):
+ synthesize.synthesize(store, query="auth", llm=True)
+
+
+def test_jsonl_synthesize_llm_passthrough(
+ store: KBStore, tmp_path: Path, monkeypatch: pytest.MonkeyPatch,
+) -> None:
+ pid = _auth_page(store)
+ _wire_llm(store, tmp_path, {
+ "answer": f"Access tokens are short-lived JWTs [{pid}].", "gaps": [],
+ })
+ monkeypatch.chdir(store.root)
+ resp = handle_request({
+ "id": "s2", "method": "kb.synthesize",
+ "params": {"query": "auth tokens", "llm": True},
+ })
+ assert resp["ok"]
+ assert resp["result"]["pages"] == [pid]
+ assert resp["result"]["_meta"]["synthesis_backend"] == "llm"
+
+
def test_capabilities_lists_synthesize() -> None:
assert "kb.synthesize" in capabilities.capabilities().methods
assert "kb.synthesize" in HANDLERS
diff --git a/webapp/src/lib/types.ts b/webapp/src/lib/types.ts
index ae3bd673..b13ae54d 100644
--- a/webapp/src/lib/types.ts
+++ b/webapp/src/lib/types.ts
@@ -18,8 +18,10 @@ export interface SynthesizeResult {
query: string
answer: string
claims: string[]
+ /** Cited page ids (llm backend only; absent on older servers). */
+ pages?: string[]
gaps: string[]
- _meta?: { synthesis_confidence?: Confidence }
+ _meta?: { synthesis_confidence?: Confidence; synthesis_backend?: string }
}
export interface SearchHit {
diff --git a/webapp/src/views/ChatView.test.tsx b/webapp/src/views/ChatView.test.tsx
index 76493da0..8026b556 100644
--- a/webapp/src/views/ChatView.test.tsx
+++ b/webapp/src/views/ChatView.test.tsx
@@ -75,10 +75,66 @@ test('submits a query to kb.synthesize and renders the cited answer', async () =
expect(rpc).toHaveBeenCalledWith(
expect.objectContaining({ endpoint: TEST_ENDPOINT }),
'kb.synthesize',
+ { query: 'what does the vouch http server bind', llm: true },
+ )
+})
+
+test('falls back to deterministic synthesis when the llm is not configured', async () => {
+ vi.mocked(rpc).mockImplementation(async (_c, method, params) => {
+ if (method !== 'kb.synthesize') throw new Error(`unexpected ${method}`)
+ if ((params as { llm?: boolean }).llm) {
+ throw new VouchRpcError(
+ 'invalid_params',
+ 'llm synthesis is not configured — set compile.llm_cmd in .vouch/config.yaml',
+ )
+ }
+ return ANSWER
+ })
+ renderWithProviders( )
+ await ask('what does the vouch http server bind')
+ expect(await screen.findByText(/binds 127\.0\.0\.1:8731 by default/)).toBeInTheDocument()
+ expect(rpc).toHaveBeenCalledWith(
+ expect.anything(),
+ 'kb.synthesize',
{ query: 'what does the vouch http server bind' },
)
})
+test('a broken llm surfaces as an error instead of silently degrading', async () => {
+ vi.mocked(rpc).mockRejectedValue(
+ new VouchRpcError('invalid_params', 'compile.llm_cmd failed (1): boom'),
+ )
+ renderWithProviders( )
+ await ask('bind')
+ expect((await screen.findAllByText(/llm_cmd failed/)).length).toBeGreaterThan(0)
+ expect(rpc).toHaveBeenCalledTimes(1)
+})
+
+test('llm answers badge the backend and open page citations as pages', async () => {
+ const llmAnswer = {
+ query: 'how does auth work',
+ answer: 'Access tokens are short-lived JWTs [auth-overview].',
+ claims: [],
+ pages: ['auth-overview'],
+ gaps: [],
+ _meta: { synthesis_confidence: 'medium' as const, synthesis_backend: 'llm' },
+ }
+ vi.mocked(rpc).mockImplementation(async (_c, method) => {
+ if (method === 'kb.synthesize') return llmAnswer
+ if (method === 'kb.read_page')
+ return { id: 'auth-overview', title: 'Auth Overview', body: 'JWTs.', type: 'concept', status: 'active' }
+ throw new Error(`unexpected ${method}`)
+ })
+ renderWithProviders( )
+ await ask('how does auth work')
+ expect(await screen.findByTestId('synthesis-backend')).toHaveTextContent('llm')
+ await userEvent.click(screen.getByRole('button', { name: 'auth-overview' }))
+ expect(await screen.findByTestId('drawer')).toBeInTheDocument()
+ await waitFor(() =>
+ expect(rpc).toHaveBeenCalledWith(expect.anything(), 'kb.read_page', { page_id: 'auth-overview' }),
+ )
+})
+
test('clicking a citation chip opens the claim drawer', async () => {
vi.mocked(rpc).mockImplementation(async (_c, method) => {
if (method === 'kb.synthesize') return ANSWER
diff --git a/webapp/src/views/ChatView.tsx b/webapp/src/views/ChatView.tsx
index cf91d9d8..be88efa9 100644
--- a/webapp/src/views/ChatView.tsx
+++ b/webapp/src/views/ChatView.tsx
@@ -51,7 +51,15 @@ function ConfidenceBadge({ level }: { level?: Confidence }) {
)
}
-function AnswerBody({ result, onCite }: { result: SynthesizeResult; onCite: (id: string) => void }) {
+function AnswerBody({
+ result,
+ onCite,
+}: {
+ result: SynthesizeResult
+ onCite: (kind: 'claim' | 'page', id: string) => void
+}) {
+ const citeKind = (id: string): 'claim' | 'page' =>
+ result.pages?.includes(id) ? 'page' : 'claim'
if (result.answer === '') {
return (
@@ -78,7 +86,7 @@ function AnswerBody({ result, onCite }: { result: SynthesizeResult; onCite: (id:
) : (
onCite(seg.claimId)}
+ onClick={() => onCite(citeKind(seg.claimId), seg.claimId)}
className="mx-0.5 inline-block max-w-72 truncate rounded border border-accent/40 bg-accent/10 px-1.5 align-middle font-mono text-[11px] leading-5 text-accent-2 transition hover:bg-accent/20"
title={seg.claimId}
>
@@ -89,6 +97,14 @@ function AnswerBody({ result, onCite }: { result: SynthesizeResult; onCite: (id:
+ {result._meta?.synthesis_backend === 'llm' && (
+
+ llm
+
+ )}
{result.gaps.length > 0 && (
gaps: {result.gaps.map((g) => (
@@ -299,7 +315,17 @@ export function ChatView() {
if (threadEndpointRef.current !== submittedFor) return
setMessages((m) => [...m, { role: 'vouch', kind: 'search', query, result }])
} else {
- const result = await rpc(conn, 'kb.synthesize', { query: text })
+ // LLM-first: the endpoint drafts the answer with its configured
+ // compile.llm_cmd, grounded in KB pages + approved claims. Endpoints
+ // without an llm_cmd reject with "not configured" — fall back to
+ // deterministic claim synthesis so the chat still answers.
+ let result: SynthesizeResult
+ try {
+ result = await rpc(conn, 'kb.synthesize', { query: text, llm: true })
+ } catch (err) {
+ if (!(err instanceof VouchRpcError) || !/not configured/i.test(err.message)) throw err
+ result = await rpc(conn, 'kb.synthesize', { query: text })
+ }
if (threadEndpointRef.current !== submittedFor) return
setMessages((m) => [...m, { role: 'vouch', kind: 'answer', result }])
}
@@ -332,7 +358,7 @@ export function ChatView() {
) : (
)
) : (
@@ -386,7 +412,7 @@ export function ChatView() {
return (
-
setDrawer({ kind: 'claim', id })} />
+ setDrawer({ kind, id })} />
)
From 4de2b6663be4f2b65ac733084fb507b9928d3062 Mon Sep 17 00:00:00 2001
From: plind <59729252+plind-junior@users.noreply.github.com>
Date: Mon, 13 Jul 2026 06:15:29 -0400
Subject: [PATCH 02/12] feat(synthesize): llm answer backend grounded in pages,
live in chat (#467)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
implement the reserved llm=true path of kb.synthesize: retrieval picks
kb pages and approved claims, the deployment-configured compile.llm_cmd
drafts the prose, and code verifies every [id] citation against the
offered sources — invented ids are stripped, and a draft left with no
verifiable citation returns an empty answer instead of a guess. the
wire shape is unchanged plus additive pages and _meta.synthesis_backend
fields.
the console chat now asks with llm: true and falls back to
deterministic claim synthesis when no llm_cmd is configured; page
citations open the page drawer and llm answers carry a badge. cli
mirror: vouch synthesize --llm. the mcp tool gains the llm param; the
jsonl/http surface already forwarded it.
---
CHANGELOG.md | 12 ++++++++++++
1 file changed, 12 insertions(+)
diff --git a/CHANGELOG.md b/CHANGELOG.md
index d907b836..4458b62e 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -59,6 +59,18 @@ All notable changes to vouch are documented here. Format follows
changed-files list and the stacked all-files diff. selection is
per-candidate, so inspecting claude's diff never moves the codex pane.
(#294)
+- `kb.synthesize` llm backend: `llm=true` drafts the answer with the
+ deployment-configured `compile.llm_cmd`, grounded in retrieved kb pages
+ and approved claims. code still verifies every `[id]` citation against
+ the offered sources — invented ids are stripped, and a draft left with no
+ verifiable citation returns an empty answer rather than a guess. the wire
+ shape is unchanged plus additive `pages` and `_meta.synthesis_backend`
+ fields. cli mirror: `vouch synthesize --llm`; the jsonl/http surface
+ already forwarded the flag.
+- console Chat: llm answers activated — the chat asks `kb.synthesize` with
+ `llm: true` and falls back to deterministic claim synthesis when no
+ `compile.llm_cmd` is configured. page citations open the page drawer, and
+ llm answers carry an `llm` badge next to the confidence grade.
### Fixed
- `vouch digest --limit` now caps the followups-due section like the
From 6dbc9ce2fca30bdb3956f17ca29414c947d78427 Mon Sep 17 00:00:00 2001
From: plind <59729252+plind-junior@users.noreply.github.com>
Date: Mon, 13 Jul 2026 10:36:45 -0400
Subject: [PATCH 03/12] docs(readme): remove the x/twitter follow badge (#471)
drop the @vouch_dev follow badge from the header badge row; the
remaining badges (ci, pypi, mcp registry, python versions, license,
gittensor) are all project-status links rather than social ones.
---
README.md | 1 -
1 file changed, 1 deletion(-)
diff --git a/README.md b/README.md
index 39056961..d6045694 100644
--- a/README.md
+++ b/README.md
@@ -12,7 +12,6 @@
-
From c938533d524396188495b1f791fe2e04fafef966 Mon Sep 17 00:00:00 2001
From: Full Stack Developer <30417830+jsdevninja@users.noreply.github.com>
Date: Mon, 13 Jul 2026 11:23:02 -0500
Subject: [PATCH 04/12] feat(server): extend _meta.vouch_hot_memory to all
read-side kb.* tools (#261)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
* feat(server): extend _meta.vouch_hot_memory to all read-side kb.* tools
* fix: update
* test(page-filters): read kb.list_pages through the items envelope
rebasing feat/hot-memory-universal-coverage onto main's kb.list_pages
(frontmatter filters, #234-adjacent) collided with this branch's own
kb.list_pages (hot-memory envelope, #225) — both touched the same
function. the conflict resolution keeps both: filter_pages() runs first,
then the result wraps in {"items": [...], "_meta": {...}} like every
other kb.list_* method.
test_page_filters.py predates that envelope and asserted on the bare
list; update the two JSONL/MCP assertions to read result["items"].
* fix(capabilities): read host_compat from package.json, not openclaw.plugin.json
_load_host_compat (#237) has been reading openclaw.compat from
openclaw.plugin.json since it shipped in 1.2.0, so kb.capabilities.host_compat
has always reported {} — that manifest never carries a top-level "openclaw"
key (test_manifest_carries_no_dead_dialect_fields enforces this; it's a dead
field from the pre-2026.6 dialect). openclaw.compat.pluginApi has only ever
lived in package.json, alongside openclaw.extensions.
repoints _load_host_compat (renamed _PLUGIN_MANIFEST_PATH ->
_HOST_COMPAT_MANIFEST_PATH for clarity) and the mirroring test at
package.json. fixes test_capabilities_host_compat_matches_openclaw_manifest
and test_capabilities_host_compat_present_and_nonempty, both failing before
this change.
* fix(hot-memory): classify nine methods added since the last rebase
test_hot_memory_universal_coverage requires every kb.* method to be in
HOT_MEMORY_COVERED or HOT_MEMORY_EXCLUDED. main grew nine methods this
branch didn't know about: kb.activity, kb.clear_claims, kb.experts,
kb.get_skill, kb.list_sessions, kb.list_skills, kb.propose_delete,
kb.session_transcript, kb.summarize_session.
classified by shape: kb.activity joins kb.digest/kb.stats as an
aggregate; kb.experts joins kb.detect_themes as self-contained ranked
analysis; kb.list_skills/kb.get_skill/kb.list_sessions/
kb.session_transcript aren't claim/artifact reads at all; kb.propose_delete
and kb.summarize_session are write paths behind the review gate;
kb.clear_claims mutates durable state directly.
* fix(hot-memory): address review — changelog placement, deprecation version, in-repo jsonl clients
three blocking review comments on #225's hot-memory work:
- the feature's CHANGELOG "Added" bullet had landed under the immutable
[1.0.0] — 2026-06-26 section instead of [Unreleased] (a rebase artifact:
the hunk's context still matched after [1.0.0] got cut, so it merged
without conflict in the wrong place). moved it to [Unreleased] -> Added.
- LIST_ENVELOPE_DEPRECATION.remove_in said "0.3.0" while the repo is on
1.3.0, which reads as already-removed. #225 asked for one release
cycle, so "1.4.0".
- the repo's own jsonl clients still read the pre-envelope flat-list shape
and none of them run in CI: adapters/jsonl-shell/example-pipeline.sh,
adapters/jsonl-shell/README.md (two spots), examples/browse-and-read/
run.sh's first_field() helper, and — found while grepping for other
.result[] consumers per the review — two assertions in
examples/review-gate-dry-run-preview/run.sh and the pending-count
tracking in examples/sessions-and-crystallize/run.sh. all now read
result.items. verified by running each script end to end (jq-based
ones use jq, which isn't installed here, but the three python-parsed
scripts ran and passed).
---
CHANGELOG.md | 35 +-
adapters/jsonl-shell/README.md | 4 +-
adapters/jsonl-shell/example-pipeline.sh | 2 +-
examples/browse-and-read/run.sh | 3 +-
examples/review-gate-dry-run-preview/run.sh | 7 +-
examples/sessions-and-crystallize/run.sh | 7 +-
schemas/capabilities.schema.json | 8 +-
src/vouch/capabilities.py | 6 +
src/vouch/hot_memory.py | 300 ++++++++++++++++-
src/vouch/jsonl_server.py | 109 ++++--
src/vouch/models.py | 6 +-
src/vouch/server.py | 108 ++++--
tests/test_hot_memory.py | 346 ++++++++++++++++++++
tests/test_jsonl_server.py | 2 +-
tests/test_page_filters.py | 10 +-
15 files changed, 865 insertions(+), 88 deletions(-)
create mode 100644 tests/test_hot_memory.py
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 4458b62e..5af930ac 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -59,23 +59,34 @@ All notable changes to vouch are documented here. Format follows
changed-files list and the stacked all-files diff. selection is
per-candidate, so inspecting claude's diff never moves the codex pane.
(#294)
-- `kb.synthesize` llm backend: `llm=true` drafts the answer with the
- deployment-configured `compile.llm_cmd`, grounded in retrieved kb pages
- and approved claims. code still verifies every `[id]` citation against
- the offered sources — invented ids are stripped, and a draft left with no
- verifiable citation returns an empty answer rather than a guess. the wire
- shape is unchanged plus additive `pages` and `_meta.synthesis_backend`
- fields. cli mirror: `vouch synthesize --llm`; the jsonl/http surface
- already forwarded the flag.
-- console Chat: llm answers activated — the chat asks `kb.synthesize` with
- `llm: true` and falls back to deterministic claim synthesis when no
- `compile.llm_cmd` is configured. page citations open the page drawer, and
- llm answers carry an `llm` badge next to the confidence grade.
+- ``_meta.vouch_hot_memory`` on every primary read-side ``kb.*`` response
+ (``kb.search``, ``kb.context``, ``kb.read_*``, ``kb.list_*``): a TTL-cached
+ sidebar of recently approved claims, query-biased where the tool has a
+ natural anchor (entity name/aliases, page title/tags, claim text, search
+ query). ``kb.list_pending`` uses recency only. Meta-tools, write paths, and
+ lifecycle ops are excluded by design (#225).
+
+### Changed
+- ``kb.list_*`` JSONL/MCP responses now use a dict envelope
+ ``{"items": [...], "_meta": {...}}`` instead of a bare list. A one-release
+ deprecation note lives at ``_meta.deprecation``; read ``result.items`` instead
+ of treating ``result`` as the list. When the KB has recent approved claims,
+ ``_meta.vouch_hot_memory`` carries the same recency sidebar as other read
+ tools (#225).
+- ``kb.capabilities`` advertises the hot-memory contract under ``hot_memory``
+ (sidebar key, list-envelope flag, covered method list).
### Fixed
- `vouch digest --limit` now caps the followups-due section like the
pending, decisions, and stale sections — it previously returned every
due followup regardless of the limit, contradicting the `--limit` help.
+- `kb.capabilities.host_compat` always reported `{}`: `_load_host_compat`
+ (#237) read `openclaw.compat` from `openclaw.plugin.json`, but that
+ manifest may not carry a top-level `openclaw` key at all (enforced by
+ `test_manifest_carries_no_dead_dialect_fields`) — `openclaw.compat.pluginApi`
+ has only ever lived in `package.json`. Repointed `_load_host_compat` (now
+ reading `_PACKAGE_JSON_PATH`) at `package.json`, where the value has been
+ present all along.
- the dual-solve diff renderer dropped added/removed lines whose content
starts with `++`/`--` (e.g. an added `++counter` line) by treating them as
diff --git a/adapters/jsonl-shell/README.md b/adapters/jsonl-shell/README.md
index a642b527..e7c4a53b 100644
--- a/adapters/jsonl-shell/README.md
+++ b/adapters/jsonl-shell/README.md
@@ -32,7 +32,7 @@ cat my-rfc.md | jq -Rs '{
```bash
echo '{"id":"r1","method":"kb.list_pending"}' \
| vouch serve --transport jsonl \
- | jq -r '.result[] | [.id, .kind, .proposed_by, .proposed_at] | @tsv' \
+ | jq -r '.result.items[] | [.id, .kind, .proposed_by, .proposed_at] | @tsv' \
| column -t
```
@@ -50,7 +50,7 @@ set -euo pipefail
pending=$(echo '{"id":"r1","method":"kb.list_pending"}' \
| vouch serve --transport jsonl \
- | jq -r '.result[] | select(.proposed_by == "release-notes-bot") | .id')
+ | jq -r '.result.items[] | select(.proposed_by == "release-notes-bot") | .id')
for id in $pending; do
echo "{\"id\":\"r1\",\"method\":\"kb.approve\",\"params\":{\"proposal_id\":\"$id\",\"reason\":\"auto: release-notes-bot\"}}" \
diff --git a/adapters/jsonl-shell/example-pipeline.sh b/adapters/jsonl-shell/example-pipeline.sh
index a77d64a2..65adaa8e 100755
--- a/adapters/jsonl-shell/example-pipeline.sh
+++ b/adapters/jsonl-shell/example-pipeline.sh
@@ -33,4 +33,4 @@ echo "$prop" | jq
# 3. List what's pending.
echo '{"id":"r3","method":"kb.list_pending"}' \
| vouch serve --transport jsonl \
- | jq '.result[] | {id, kind, proposed_by}'
+ | jq '.result.items[] | {id, kind, proposed_by}'
diff --git a/examples/browse-and-read/run.sh b/examples/browse-and-read/run.sh
index e281b2e3..527bcf92 100755
--- a/examples/browse-and-read/run.sh
+++ b/examples/browse-and-read/run.sh
@@ -46,6 +46,7 @@ print(out)
}
# pull a field from the first element of a list-typed result
+# (kb.list_* responses are the {"items": [...], "_meta": {...}} envelope)
first_field() {
python3 -c '
import json, sys
@@ -57,7 +58,7 @@ for line in sys.stdin:
continue
row = json.loads(line)
if row.get("id") == want_id:
- out = row["result"][0][key]
+ out = row["result"]["items"][0][key]
print(out)
' "$1" "$2"
}
diff --git a/examples/review-gate-dry-run-preview/run.sh b/examples/review-gate-dry-run-preview/run.sh
index 3b8f034f..363e2ddf 100755
--- a/examples/review-gate-dry-run-preview/run.sh
+++ b/examples/review-gate-dry-run-preview/run.sh
@@ -108,17 +108,18 @@ assert p["result"]["dry_run"] is True, p
assert p["result"]["status"] == "pending", p
print("ok dry_run:true returned a preview (status=pending, dry_run=true)")
-# 2. the preview left the queue empty.
+# 2. the preview left the queue empty. kb.list_pending returns the
+# {"items": [...], "_meta": {...}} envelope, not a bare list.
empty = preview["pending-after-preview"]
assert empty["ok"], empty
-assert empty["result"] == [], empty
+assert empty["result"]["items"] == [], empty
print("ok list_pending empty after the dry-run preview")
# 3. a real propose files exactly one pending proposal.
f = filed["file"]
assert f["ok"] and f["result"]["dry_run"] is False, f
after = filed["pending-after-file"]
-assert len(after["result"]) == 1, after
+assert len(after["result"]["items"]) == 1, after
print(f"ok propose_claim filed 1 pending proposal: {f['result']['proposal_id']}")
# 4. approve produces a durable claim.
diff --git a/examples/sessions-and-crystallize/run.sh b/examples/sessions-and-crystallize/run.sh
index 4c4c1340..28d63273 100755
--- a/examples/sessions-and-crystallize/run.sh
+++ b/examples/sessions-and-crystallize/run.sh
@@ -104,8 +104,9 @@ print(f"volunteer_context -> {len(offers)} offer(s)")
for o in offers:
print(f" claim={o['claim_id']} relevance={o['relevance']:.2f}")
-# Pending count before the batch approve.
-before = call("lp", "kb.list_pending", {})
+# Pending count before the batch approve. kb.list_pending returns the
+# {"items": [...], "_meta": {...}} envelope, not a bare list.
+before = call("lp", "kb.list_pending", {})["items"]
print(f"list_pending (before crystallize) -> {len(before)} pending")
# session_end closes the run and backfills its proposal ids.
@@ -119,7 +120,7 @@ print(f"crystallize -> approved={cr['approved']}")
print(f" -> summary_page_id={cr['summary_page_id']}")
# Pending count after: the session's proposals are gone.
-after = call("lp2", "kb.list_pending", {})
+after = call("lp2", "kb.list_pending", {})["items"]
print(f"list_pending (after crystallize) -> {len(after)} pending")
proc.stdin.close()
diff --git a/schemas/capabilities.schema.json b/schemas/capabilities.schema.json
index 118969f0..0832cad1 100644
--- a/schemas/capabilities.schema.json
+++ b/schemas/capabilities.schema.json
@@ -14,10 +14,16 @@
},
"host_compat": {
"additionalProperties": true,
- "description": "Per-host compatibility ranges (#237). Mirrors the `openclaw.compat` block in openclaw.plugin.json so non-OpenClaw clients can detect compat without parsing the manifest, e.g. {\"openclaw\": {\"pluginApi\": \">=2026.4.0\"}}.",
+ "description": "Per-host compatibility ranges (#237). Mirrors the `openclaw.compat` block in package.json so non-OpenClaw clients can detect compat without parsing the manifest, e.g. {\"openclaw\": {\"pluginApi\": \">=2026.4.0\"}}.",
"title": "Host Compat",
"type": "object"
},
+ "hot_memory": {
+ "additionalProperties": true,
+ "description": "Hot-memory sidebar contract on read-side kb.* responses",
+ "title": "Hot Memory",
+ "type": "object"
+ },
"knowledge_capability": {
"additionalProperties": true,
"title": "Knowledge Capability",
diff --git a/src/vouch/capabilities.py b/src/vouch/capabilities.py
index bbf2f940..9d50f305 100644
--- a/src/vouch/capabilities.py
+++ b/src/vouch/capabilities.py
@@ -12,6 +12,7 @@
from pathlib import Path
from . import __version__
+from . import hot_memory as hot_mod
from .models import Capabilities
from .openclaw.context_engine import describe_engine
@@ -145,4 +146,9 @@ def capabilities(*, publish_skills: bool = True) -> Capabilities:
context_engines=[describe_engine()],
mcp={"publish_skills": publish_skills},
host_compat=_load_host_compat(),
+ hot_memory={
+ "sidebar_key": "vouch_hot_memory",
+ "list_envelope": True,
+ "covered_methods": sorted(hot_mod.HOT_MEMORY_COVERED),
+ },
)
diff --git a/src/vouch/hot_memory.py b/src/vouch/hot_memory.py
index 1af9c588..b6eae608 100644
--- a/src/vouch/hot_memory.py
+++ b/src/vouch/hot_memory.py
@@ -1,14 +1,26 @@
-"""Per-session hot memory — task query and salience snapshots for push context.
+"""Hot memory — session watch state and read-side response sidebars.
-Tracks what the active session is working on and the last relevance scores
-seen for approved claims. ``volunteer_context`` diffs snapshots to decide when
-a claim newly crosses the confidence threshold.
+Two concerns live here:
+
+1. **Session registry** — tracks what the active session is working on and the
+ last relevance scores seen for approved claims. ``volunteer_context`` diffs
+ snapshots to decide when a claim newly crosses the confidence threshold.
+
+2. **Response sidebar** — inspired by gbrain's ``_meta.brain_hot_memory``
+ pattern: read-side ``kb.*`` responses carry a small sidebar of recently
+ approved claims so the agent doesn't re-query for "what just changed?"
+ between turns. Strictly read-side; TTL-cached in-process.
"""
from __future__ import annotations
import threading
+import time
from dataclasses import dataclass, field
+from typing import Any
+
+from .models import ClaimStatus, Entity, Page
+from .storage import KBStore
@dataclass
@@ -87,3 +99,283 @@ def mark_volunteered(session_id: str, claim_id: str, *, pushed_at: float) -> Non
mem.volunteered.add(claim_id)
mem.last_push_at = pushed_at
mem.push_count += 1
+
+
+# === response sidebar (gbrain ``_meta.brain_hot_memory`` pattern) =========
+
+
+DEFAULT_TTL_SECONDS = 30.0
+DEFAULT_LIMIT = 5
+DEFAULT_MAX_AGE_SECONDS = 7 * 24 * 3600 # 1 week
+TEXT_PREVIEW_CHARS = 200
+
+LIST_ENVELOPE_DEPRECATION: dict[str, str] = {
+ "message": (
+ "kb.list_* responses now use a dict envelope with an items key; "
+ "reading the flat list at result directly is deprecated"
+ ),
+ "migration": "use result.items instead of result when result is a list",
+ "remove_in": "1.4.0",
+}
+
+# kb.* methods that attach ``_meta.vouch_hot_memory`` on read responses.
+HOT_MEMORY_COVERED: frozenset[str] = frozenset({
+ "kb.search",
+ "kb.context",
+ "kb.read_page",
+ "kb.read_claim",
+ "kb.read_entity",
+ "kb.read_relation",
+ "kb.list_pages",
+ "kb.list_claims",
+ "kb.list_entities",
+ "kb.list_relations",
+ "kb.list_sources",
+ "kb.list_pending",
+})
+
+# Explicit exclusions for ``test_hot_memory_universal_coverage``.
+HOT_MEMORY_EXCLUDED: dict[str, str] = {
+ "kb.capabilities": "meta-tool — no KB payload to decorate",
+ "kb.status": "meta-tool — health summary only",
+ "kb.stats": "aggregates — sidebar would duplicate counts",
+ "kb.digest": "aggregated reviewer briefing — sidebar would duplicate its own recency content",
+ "kb.activity": "aggregated audit-log buckets — sidebar would duplicate counts",
+ "kb.neighbors": "graph slice — out of scope for recency sidebar",
+ "kb.synthesize": "answer-mode prose — sidebar adds noise",
+ "kb.diff": "field-level revision diff — self-contained, not a claim browse",
+ "kb.detect_themes": "cluster analysis — self-contained, not a claim browse",
+ "kb.experts": "ranked entity analysis — self-contained, not a claim browse",
+ "kb.triage_pending": (
+ "advisory overlay on kb.list_pending — carries its own _meta.vouch_triage per item"
+ ),
+ "kb.list_skills": "skill/command catalogue — not a claim or artifact browse",
+ "kb.get_skill": "skill/command catalogue — not a claim or artifact browse",
+ "kb.list_sessions": "session pipeline rows — different shape from claim/page reads",
+ "kb.session_transcript": "raw session transcript — different shape from claim reads",
+ "kb.register_source": "write path — review gate",
+ "kb.register_source_from_path": "write path — review gate",
+ "kb.propose_claim": "write path — review gate",
+ "kb.propose_page": "write path — review gate",
+ "kb.propose_entity": "write path — review gate",
+ "kb.propose_relation": "write path — review gate",
+ "kb.propose_theme": "write path — review gate",
+ "kb.propose_delete": "write path — review gate",
+ "kb.compile": "write path — review gate (files page proposals via wiki-compiler)",
+ "kb.summarize_session": "write path — review gate (files session-summary page proposals)",
+ "kb.clear_claims": "lifecycle — mutates durable state",
+ "kb.approve": "lifecycle — mutates durable state",
+ "kb.reject": "lifecycle — mutates durable state",
+ "kb.reject_extracted": "lifecycle — mutates durable state",
+ "kb.expire": "lifecycle — mutates durable state",
+ "kb.supersede": "lifecycle — mutates durable state",
+ "kb.contradict": "lifecycle — mutates durable state",
+ "kb.archive": "lifecycle — mutates durable state",
+ "kb.confirm": "lifecycle — mutates durable state",
+ "kb.cite": "lifecycle — mutates durable state",
+ "kb.source_verify": "write path — verification intake",
+ "kb.session_start": "session control — not a KB read",
+ "kb.session_end": "session control — not a KB read",
+ "kb.volunteer_context": "push channel — already surfaces hot claims",
+ "kb.crystallize": "write path — proposal intake",
+ "kb.index_rebuild": "maintenance — mutates derived index",
+ "kb.lint": "diagnostics — no claim payload",
+ "kb.doctor": "diagnostics — no claim payload",
+ "kb.export": "bundle write — not a read response",
+ "kb.export_check": "preflight — no claim sidebar needed",
+ "kb.import_check": "preflight — no claim sidebar needed",
+ "kb.import_apply": "mutation — applies bundle",
+ "kb.audit": "event log — different shape from claim reads",
+ "kb.reindex_embeddings": "maintenance — mutates derived index",
+ "kb.dedup_scan": "analysis — not a standard read",
+ "kb.eval_embeddings": "benchmark — not a standard read",
+ "kb.embeddings_stats": "index stats — no claim payload",
+ "kb.why": "provenance trace — self-contained",
+ "kb.trace": "provenance trace — self-contained",
+ "kb.impact": "graph impact — self-contained",
+ "kb.graph_export": "bulk export — sidebar too large",
+ "kb.provenance_rebuild": "maintenance — mutates derived state",
+}
+
+
+@dataclass(frozen=True)
+class _CacheKey:
+ kb_dir: str
+ query_norm: str
+ limit: int
+ max_age_seconds: int
+
+
+_SIDEBAR_CACHE: dict[_CacheKey, tuple[float, list[dict[str, Any]]]] = {}
+
+
+def reset_sidebar_cache() -> None:
+ """Drop every sidebar cache entry. Tests call this between cases."""
+ _SIDEBAR_CACHE.clear()
+
+
+def reset_cache() -> None:
+ """Alias for tests that clear the sidebar TTL cache."""
+ reset_sidebar_cache()
+
+
+def _normalise_query(query: str | None) -> str:
+ if not query:
+ return ""
+ return " ".join(query.lower().split())
+
+
+def _preview(text: str) -> str:
+ flat = " ".join(text.strip().split())
+ if len(flat) <= TEXT_PREVIEW_CHARS:
+ return flat
+ return flat[: TEXT_PREVIEW_CHARS - 1] + "…"
+
+
+def _is_active(status: ClaimStatus) -> bool:
+ return status in {ClaimStatus.WORKING, ClaimStatus.STABLE, ClaimStatus.CONTESTED}
+
+
+def query_bias_for_page(page: Page) -> str:
+ """Bias hot-memory toward page title and tags."""
+ return " ".join([page.title, *page.tags])
+
+
+def query_bias_for_entity(entity: Entity) -> str:
+ """Bias hot-memory toward entity name and aliases."""
+ return " ".join([entity.name, *entity.aliases])
+
+
+def compute_hot_memory(
+ store: KBStore,
+ *,
+ query: str | None = None,
+ limit: int = DEFAULT_LIMIT,
+ exclude_ids: list[str] | None = None,
+ max_age_seconds: int = DEFAULT_MAX_AGE_SECONDS,
+ ttl_seconds: float = DEFAULT_TTL_SECONDS,
+ now: float | None = None,
+) -> list[dict[str, Any]]:
+ """Return up to ``limit`` recently-approved claims relevant to ``query``."""
+ if limit <= 0:
+ return []
+
+ now = time.monotonic() if now is None else now
+ query_norm = _normalise_query(query)
+ key = _CacheKey(
+ kb_dir=str(store.kb_dir),
+ query_norm=query_norm,
+ limit=limit,
+ max_age_seconds=max_age_seconds,
+ )
+ cached = _SIDEBAR_CACHE.get(key)
+ if cached is not None and (now - cached[0]) < ttl_seconds:
+ rows = cached[1]
+ else:
+ rows = _compute_sidebar(store, query_norm, limit, max_age_seconds)
+ _SIDEBAR_CACHE[key] = (now, rows)
+
+ if not exclude_ids:
+ return list(rows)
+ excluded = set(exclude_ids)
+ return [r for r in rows if r["id"] not in excluded]
+
+
+def _matches_query(query_norm: str, text_lower: str) -> bool:
+ if not query_norm:
+ return False
+ if query_norm in text_lower:
+ return True
+ return any(
+ len(token) >= 3 and token in text_lower for token in query_norm.split()
+ )
+
+
+def _compute_sidebar(
+ store: KBStore,
+ query_norm: str,
+ limit: int,
+ max_age_seconds: int,
+) -> list[dict[str, Any]]:
+ from datetime import UTC, datetime
+
+ try:
+ claims = store.list_claims()
+ except Exception:
+ return []
+
+ cutoff = datetime.now(UTC).timestamp() - max_age_seconds
+ candidates: list[tuple[float, datetime, Any, str]] = []
+
+ for c in claims:
+ if not _is_active(c.status):
+ continue
+ ts_dt = c.last_confirmed_at or c.updated_at
+ ts = ts_dt.timestamp()
+ if ts < cutoff:
+ continue
+ text_lower = c.text.lower()
+ matches_query = _matches_query(query_norm, text_lower)
+ score = ts + (3600.0 if matches_query else 0.0)
+ why = "recent+match" if matches_query else "recent"
+ candidates.append((score, ts_dt, c, why))
+
+ candidates.sort(key=lambda row: row[0], reverse=True)
+
+ out: list[dict[str, Any]] = []
+ for _score, ts_dt, c, why in candidates[:limit]:
+ out.append({
+ "id": c.id,
+ "text": _preview(c.text),
+ "type": c.type.value,
+ "status": c.status.value,
+ "citations": list(c.evidence),
+ "approved_by": c.approved_by,
+ "approved_at": ts_dt.isoformat(timespec="seconds"),
+ "why_hot": why,
+ })
+ return out
+
+
+def attach_hot_memory(
+ result: Any,
+ store: KBStore,
+ *,
+ query: str | None = None,
+ limit: int = DEFAULT_LIMIT,
+ exclude_ids: list[str] | None = None,
+ list_envelope: bool = False,
+) -> Any:
+ """Attach ``_meta.vouch_hot_memory`` to *result* when the sidebar is non-empty.
+
+ When *list_envelope* is true and *result* is a list, always wrap as
+ ``{"items": [...], "_meta": {...}}`` and include a one-release-cycle
+ deprecation note for JSONL clients that expected a flat list.
+ """
+ sidebar = compute_hot_memory(
+ store, query=query, limit=limit, exclude_ids=exclude_ids,
+ )
+
+ if isinstance(result, list) and list_envelope:
+ meta: dict[str, Any] = {"deprecation": dict(LIST_ENVELOPE_DEPRECATION)}
+ if sidebar:
+ meta["vouch_hot_memory"] = sidebar
+ return {"items": result, "_meta": meta}
+
+ if not sidebar:
+ return result
+
+ meta = {"vouch_hot_memory": sidebar}
+
+ if isinstance(result, dict):
+ existing_meta = result.get("_meta")
+ if isinstance(existing_meta, dict):
+ existing_meta.update(meta)
+ else:
+ result["_meta"] = meta
+ return result
+
+ if isinstance(result, list):
+ return {"items": result, "_meta": meta}
+
+ return result
diff --git a/src/vouch/jsonl_server.py b/src/vouch/jsonl_server.py
index e3785576..6e2fc6ab 100644
--- a/src/vouch/jsonl_server.py
+++ b/src/vouch/jsonl_server.py
@@ -31,6 +31,7 @@
from . import audit, bundle, health, volunteer_context
from . import compile as compile_mod
from . import digest as digest_mod
+from . import hot_memory as hot_mod
from . import lifecycle as life
from . import metrics as metrics_mod
from . import salience as salience_mod
@@ -188,14 +189,18 @@ def _h_search(p: dict) -> dict:
used = "hybrid"
scoped = filter_hits(s, hits, viewer, limit=limit)
- return {
+ hits_list = [
+ {"kind": k, "id": i, "snippet": sn, "score": sc, "backend": used}
+ for k, i, sn, sc in scoped
+ ]
+ result: dict[str, Any] = {
"backend": used,
"viewer": {"project": viewer.project, "agent": viewer.agent},
- "hits": [
- {"kind": k, "id": i, "snippet": sn, "score": sc, "backend": used}
- for k, i, sn, sc in scoped
- ],
+ "hits": hits_list,
}
+ return hot_mod.attach_hot_memory( # type: ignore[no-any-return]
+ result, s, query=q, exclude_ids=[str(hit["id"]) for hit in hits_list],
+ )
def _load_cfg(store: KBStore) -> dict:
@@ -256,7 +261,12 @@ def _h_context(p: dict) -> dict:
graph_limit=int(p.get("graph_limit", 20)),
graph_rel_types=p.get("graph_rel_types"),
)
- return salience_mod.attach_salience(result, store, session_id, cfg)
+ result = salience_mod.attach_salience(result, store, session_id, cfg)
+ pack_items = result.get("items") if isinstance(result, dict) else None
+ exclude = [it.get("id") for it in pack_items] if isinstance(pack_items, list) else []
+ return hot_mod.attach_hot_memory( # type: ignore[no-any-return]
+ result, store, query=query, exclude_ids=[i for i in exclude if i],
+ )
def _h_synthesize(p: dict) -> dict:
@@ -270,19 +280,39 @@ def _h_synthesize(p: dict) -> dict:
def _h_read_page(p: dict) -> dict:
- return _store().get_page(p["page_id"]).model_dump(mode="json")
+ store = _store()
+ page = store.get_page(p["page_id"])
+ result = page.model_dump(mode="json")
+ return hot_mod.attach_hot_memory( # type: ignore[no-any-return]
+ result, store, query=hot_mod.query_bias_for_page(page),
+ )
def _h_read_claim(p: dict) -> dict:
- return _store().get_claim(p["claim_id"]).model_dump(mode="json")
+ store = _store()
+ claim = store.get_claim(p["claim_id"])
+ result = claim.model_dump(mode="json")
+ return hot_mod.attach_hot_memory( # type: ignore[no-any-return]
+ result, store, query=claim.text, exclude_ids=[claim.id],
+ )
def _h_read_entity(p: dict) -> dict:
- return _store().get_entity(p["entity_id"]).model_dump(mode="json")
+ store = _store()
+ entity = store.get_entity(p["entity_id"])
+ result = entity.model_dump(mode="json")
+ return hot_mod.attach_hot_memory( # type: ignore[no-any-return]
+ result, store, query=hot_mod.query_bias_for_entity(entity),
+ )
def _h_read_relation(p: dict) -> dict:
- return _store().get_relation(p["relation_id"]).model_dump(mode="json")
+ store = _store()
+ relation = store.get_relation(p["relation_id"])
+ result = relation.model_dump(mode="json")
+ return hot_mod.attach_hot_memory( # type: ignore[no-any-return]
+ result, store, query=None,
+ )
def _h_diff(p: dict) -> dict:
@@ -293,49 +323,72 @@ def _h_diff(p: dict) -> dict:
return asdict(diff_artifacts(_store(), p["old_id"], p.get("new_id")))
-def _h_list_pages(p: dict) -> list[dict]:
+def _h_list_pages(p: dict) -> dict:
+ store = _store()
pages = filter_pages(
- _store().list_pages(),
+ store.list_pages(),
kind=p.get("type"),
equals=p.get("meta"),
before=p.get("meta_before"),
after=p.get("meta_after"),
)
- return [pg.model_dump(mode="json") for pg in pages]
+ items = [pg.model_dump(mode="json") for pg in pages]
+ return hot_mod.attach_hot_memory( # type: ignore[no-any-return]
+ items, store, query=None, list_envelope=True,
+ )
-def _h_list_claims(p: dict) -> list[dict]:
- cs = _store().list_claims()
+def _h_list_claims(p: dict) -> dict:
+ store = _store()
+ cs = store.list_claims()
if p.get("status"):
cs = [c for c in cs if c.status.value == p["status"]]
- return [c.model_dump(mode="json") for c in cs]
+ items = [c.model_dump(mode="json") for c in cs]
+ return hot_mod.attach_hot_memory( # type: ignore[no-any-return]
+ items, store, query=None, list_envelope=True,
+ )
-def _h_list_entities(p: dict) -> list[dict]:
- es = _store().list_entities()
+def _h_list_entities(p: dict) -> dict:
+ store = _store()
+ es = store.list_entities()
if p.get("entity_type"):
es = [e for e in es if e.type.value == p["entity_type"]]
- return [e.model_dump(mode="json") for e in es]
+ items = [e.model_dump(mode="json") for e in es]
+ return hot_mod.attach_hot_memory( # type: ignore[no-any-return]
+ items, store, query=None, list_envelope=True,
+ )
-def _h_list_relations(p: dict) -> list[dict]:
- s = _store()
- rels = s.list_relations()
+def _h_list_relations(p: dict) -> dict:
+ store = _store()
+ rels = store.list_relations()
node = p.get("node_id")
if node:
rels = [r for r in rels if r.source == node or r.target == node]
- return [r.model_dump(mode="json") for r in rels]
+ items = [r.model_dump(mode="json") for r in rels]
+ return hot_mod.attach_hot_memory( # type: ignore[no-any-return]
+ items, store, query=None, list_envelope=True,
+ )
-def _h_list_sources(_: dict) -> list[dict]:
- return [s.model_dump(mode="json") for s in _store().list_sources()]
+def _h_list_sources(_: dict) -> dict:
+ store = _store()
+ items = [s.model_dump(mode="json") for s in store.list_sources()]
+ return hot_mod.attach_hot_memory( # type: ignore[no-any-return]
+ items, store, query=None, list_envelope=True,
+ )
-def _h_list_pending(_: dict) -> list[dict]:
- return [
+def _h_list_pending(_: dict) -> dict:
+ store = _store()
+ items = [
p.model_dump(mode="json")
- for p in _store().list_proposals(ProposalStatus.PENDING)
+ for p in store.list_proposals(ProposalStatus.PENDING)
]
+ return hot_mod.attach_hot_memory( # type: ignore[no-any-return]
+ items, store, query=None, list_envelope=True,
+ )
def _h_triage_pending(p: dict) -> list[dict]:
diff --git a/src/vouch/models.py b/src/vouch/models.py
index 7946f30f..701e4a01 100644
--- a/src/vouch/models.py
+++ b/src/vouch/models.py
@@ -507,8 +507,12 @@ class Capabilities(BaseModel):
default_factory=dict,
description=(
"Per-host compatibility ranges (#237). Mirrors the "
- "`openclaw.compat` block in openclaw.plugin.json so non-OpenClaw "
+ "`openclaw.compat` block in package.json so non-OpenClaw "
"clients can detect compat without parsing the manifest, e.g. "
'{"openclaw": {"pluginApi": ">=2026.4.0"}}.'
),
)
+ hot_memory: dict[str, Any] = Field(
+ default_factory=dict,
+ description="Hot-memory sidebar contract on read-side kb.* responses",
+ )
diff --git a/src/vouch/server.py b/src/vouch/server.py
index 7f4bfde5..b78c7ca1 100644
--- a/src/vouch/server.py
+++ b/src/vouch/server.py
@@ -22,6 +22,7 @@
from . import audit, bundle, health, mcp_profiles, volunteer_context
from . import compile as compile_mod
from . import digest as digest_mod
+from . import hot_memory as hot_mod
from . import lifecycle as life
from . import metrics as metrics_mod
from . import salience as salience_mod
@@ -213,14 +214,19 @@ def kb_search(
def _to_dicts(h: list[tuple[str, str, str, float]], used: str) -> dict[str, Any]:
scoped = filter_hits(store, h, viewer, limit=limit)
- return {
+ hits_list: list[dict[str, Any]] = [
+ {"kind": k, "id": i, "snippet": sn, "score": sc, "backend": used}
+ for k, i, sn, sc in scoped
+ ]
+ result: dict[str, Any] = {
"backend": used,
"viewer": {"project": viewer.project, "agent": viewer.agent},
- "hits": [
- {"kind": k, "id": i, "snippet": sn, "score": sc, "backend": used}
- for k, i, sn, sc in scoped
- ],
+ "hits": hits_list,
}
+ return hot_mod.attach_hot_memory( # type: ignore[no-any-return]
+ result, store, query=query,
+ exclude_ids=[str(hit["id"]) for hit in hits_list],
+ )
if backend in ("auto", "embedding"):
hits = index_db.search_semantic(
@@ -338,7 +344,12 @@ def kb_context(
project=project, agent=agent,
expand_graph=expand_graph, graph_depth=graph_depth, graph_limit=graph_limit,
)
- return salience_mod.attach_salience(result, store, session_id, cfg)
+ result = salience_mod.attach_salience(result, store, session_id, cfg)
+ pack_items = result.get("items") if isinstance(result, dict) else None
+ exclude = [it.get("id") for it in pack_items] if isinstance(pack_items, list) else []
+ return hot_mod.attach_hot_memory( # type: ignore[no-any-return]
+ result, store, query=task, exclude_ids=[i for i in exclude if i],
+ )
@mcp.tool()
@@ -365,35 +376,55 @@ def kb_synthesize(
@mcp.tool()
def kb_read_page(page_id: str) -> dict[str, Any]:
"""Return a page (title, body, claim ids)."""
+ store = _store()
try:
- return _store().get_page(page_id).model_dump(mode="json")
+ page = store.get_page(page_id)
except ArtifactNotFoundError as e:
raise ValueError(str(e)) from e
+ result = page.model_dump(mode="json")
+ return hot_mod.attach_hot_memory( # type: ignore[no-any-return]
+ result, store, query=hot_mod.query_bias_for_page(page),
+ )
@mcp.tool()
def kb_read_claim(claim_id: str) -> dict[str, Any]:
"""Return a claim with its citation list."""
+ store = _store()
try:
- return _store().get_claim(claim_id).model_dump(mode="json")
+ claim = store.get_claim(claim_id)
except ArtifactNotFoundError as e:
raise ValueError(str(e)) from e
+ result = claim.model_dump(mode="json")
+ return hot_mod.attach_hot_memory( # type: ignore[no-any-return]
+ result, store, query=claim.text, exclude_ids=[claim.id],
+ )
@mcp.tool()
def kb_read_entity(entity_id: str) -> dict[str, Any]:
+ store = _store()
try:
- return _store().get_entity(entity_id).model_dump(mode="json")
+ entity = store.get_entity(entity_id)
except ArtifactNotFoundError as e:
raise ValueError(str(e)) from e
+ result = entity.model_dump(mode="json")
+ return hot_mod.attach_hot_memory( # type: ignore[no-any-return]
+ result, store, query=hot_mod.query_bias_for_entity(entity),
+ )
@mcp.tool()
def kb_read_relation(relation_id: str) -> dict[str, Any]:
+ store = _store()
try:
- return _store().get_relation(relation_id).model_dump(mode="json")
+ relation = store.get_relation(relation_id)
except ArtifactNotFoundError as e:
raise ValueError(str(e)) from e
+ result = relation.model_dump(mode="json")
+ return hot_mod.attach_hot_memory( # type: ignore[no-any-return]
+ result, store, query=None,
+ )
@mcp.tool()
@@ -415,7 +446,7 @@ def kb_list_pages(
meta: dict[str, str] | None = None,
meta_before: dict[str, str] | None = None,
meta_after: dict[str, str] | None = None,
-) -> list[dict[str, Any]]:
+) -> dict[str, Any]:
"""List pages, optionally filtered by kind and frontmatter.
type: page kind (built-in or config-declared, e.g. "followup").
@@ -423,62 +454,85 @@ def kb_list_pages(
meta_before / meta_after: inclusive bounds — numbers or ISO dates,
e.g. meta_before={"due_at": "2026-07-10"} for followups due by then.
"""
+ store = _store()
pages = filter_pages(
- _store().list_pages(),
+ store.list_pages(),
kind=type,
equals=meta,
before=meta_before,
after=meta_after,
)
- return [
+ items = [
{"id": p.id, "title": p.title, "type": p.type, "tags": p.tags, "metadata": p.metadata}
for p in pages
]
+ return hot_mod.attach_hot_memory( # type: ignore[no-any-return]
+ items, store, query=None, list_envelope=True,
+ )
@mcp.tool()
-def kb_list_claims(status: str | None = None) -> list[dict[str, Any]]:
+def kb_list_claims(status: str | None = None) -> dict[str, Any]:
"""List all claims, optionally filtered by status."""
- claims = _store().list_claims()
+ store = _store()
+ claims = store.list_claims()
if status:
claims = [c for c in claims if c.status.value == status]
- return [c.model_dump(mode="json") for c in claims]
+ items = [c.model_dump(mode="json") for c in claims]
+ return hot_mod.attach_hot_memory( # type: ignore[no-any-return]
+ items, store, query=None, list_envelope=True,
+ )
@mcp.tool()
-def kb_list_entities(entity_type: str | None = None) -> list[dict[str, Any]]:
- entities = _store().list_entities()
+def kb_list_entities(entity_type: str | None = None) -> dict[str, Any]:
+ store = _store()
+ entities = store.list_entities()
if entity_type:
entities = [e for e in entities if e.type.value == entity_type]
- return [e.model_dump(mode="json") for e in entities]
+ items = [e.model_dump(mode="json") for e in entities]
+ return hot_mod.attach_hot_memory( # type: ignore[no-any-return]
+ items, store, query=None, list_envelope=True,
+ )
@mcp.tool()
-def kb_list_relations(node_id: str | None = None) -> list[dict[str, Any]]:
+def kb_list_relations(node_id: str | None = None) -> dict[str, Any]:
"""List all relations; if node_id is given, only edges touching it."""
store = _store()
rels = store.list_relations()
if node_id:
rels = [r for r in rels if r.source == node_id or r.target == node_id]
- return [r.model_dump(mode="json") for r in rels]
+ items = [r.model_dump(mode="json") for r in rels]
+ return hot_mod.attach_hot_memory( # type: ignore[no-any-return]
+ items, store, query=None, list_envelope=True,
+ )
@mcp.tool()
-def kb_list_sources() -> list[dict[str, Any]]:
- return [
+def kb_list_sources() -> dict[str, Any]:
+ store = _store()
+ items = [
{"id": s.id, "title": s.title, "type": s.type.value,
"locator": s.locator, "byte_size": s.byte_size}
- for s in _store().list_sources()
+ for s in store.list_sources()
]
+ return hot_mod.attach_hot_memory( # type: ignore[no-any-return]
+ items, store, query=None, list_envelope=True,
+ )
@mcp.tool()
-def kb_list_pending() -> list[dict[str, Any]]:
+def kb_list_pending() -> dict[str, Any]:
"""List proposals awaiting human review."""
- return [
+ store = _store()
+ items = [
p.model_dump(mode="json")
- for p in _store().list_proposals(ProposalStatus.PENDING)
+ for p in store.list_proposals(ProposalStatus.PENDING)
]
+ return hot_mod.attach_hot_memory( # type: ignore[no-any-return]
+ items, store, query=None, list_envelope=True,
+ )
@mcp.tool()
diff --git a/tests/test_hot_memory.py b/tests/test_hot_memory.py
new file mode 100644
index 00000000..102444aa
--- /dev/null
+++ b/tests/test_hot_memory.py
@@ -0,0 +1,346 @@
+"""Hot-memory sidebar — gbrain's ``_meta.brain_hot_memory`` pattern."""
+
+from __future__ import annotations
+
+from datetime import UTC, datetime, timedelta
+from pathlib import Path
+
+import pytest
+
+from vouch import hot_memory as hot_mod
+from vouch.capabilities import METHODS
+from vouch.jsonl_server import handle_request
+from vouch.models import ClaimStatus, Entity, EntityType, Page, PageType
+from vouch.proposals import approve, propose_claim
+from vouch.storage import KBStore
+
+
+@pytest.fixture(autouse=True)
+def _clear_cache():
+ hot_mod.reset_sidebar_cache()
+ yield
+ hot_mod.reset_sidebar_cache()
+
+
+@pytest.fixture
+def store(tmp_path: Path) -> KBStore:
+ return KBStore.init(tmp_path)
+
+
+def _approved_claim(
+ store: KBStore, text: str, *, approver: str = "reviewer",
+) -> str:
+ src = store.put_source(b"evidence-bytes")
+ pr = propose_claim(store, text=text, evidence=[src.id], proposed_by="agent-A")
+ artifact = approve(store, pr.id, approved_by=approver)
+ return artifact.id # type: ignore[union-attr]
+
+
+# --- core sidebar shape -----------------------------------------------------
+
+
+def test_recently_approved_claim_appears(store: KBStore) -> None:
+ _approved_claim(store, "the sky is blue today")
+ rows = hot_mod.compute_hot_memory(store, limit=5)
+ assert len(rows) == 1
+ row = rows[0]
+ assert row["text"] == "the sky is blue today"
+ assert row["status"] == ClaimStatus.WORKING.value
+ assert row["why_hot"] == "recent"
+ assert "approved_at" in row
+
+
+def test_empty_kb_yields_empty_list(store: KBStore) -> None:
+ assert hot_mod.compute_hot_memory(store, limit=5) == []
+
+
+def test_limit_caps_result_size(store: KBStore) -> None:
+ for i in range(5):
+ _approved_claim(store, f"claim number {i}")
+ rows = hot_mod.compute_hot_memory(store, limit=2)
+ assert len(rows) == 2
+
+
+# --- query bias -------------------------------------------------------------
+
+
+def test_query_bias_boosts_matching_claim(store: KBStore) -> None:
+ id_off = _approved_claim(store, "the moon orbits earth")
+ id_on = _approved_claim(store, "kafka partitioning explained")
+ rows = hot_mod.compute_hot_memory(store, query="kafka", limit=5)
+ assert rows[0]["id"] == id_on
+ assert rows[0]["why_hot"] == "recent+match"
+ assert any(r["id"] == id_off for r in rows)
+
+
+def test_query_bias_for_entity_name_and_aliases(store: KBStore) -> None:
+ _approved_claim(store, "acme corp uses kafka for events")
+ _approved_claim(store, "unrelated weather report")
+ entity = Entity(id="ent-acme", name="Acme Corp", type=EntityType.COMPANY,
+ aliases=["ACME", "Acme"])
+ store.put_entity(entity)
+ bias = hot_mod.query_bias_for_entity(entity)
+ rows = hot_mod.compute_hot_memory(store, query=bias, limit=5)
+ assert rows[0]["why_hot"] == "recent+match"
+
+
+def test_query_bias_for_page_title_and_tags(store: KBStore) -> None:
+ _approved_claim(store, "security review checklist for deploys")
+ _approved_claim(store, "unrelated lunch menu")
+ page = Page(id="pg-sec", title="Security Review", type=PageType.DECISION.value,
+ tags=["security", "deploy"])
+ store.put_page(page)
+ bias = hot_mod.query_bias_for_page(page)
+ rows = hot_mod.compute_hot_memory(store, query=bias, limit=5)
+ assert any(r["why_hot"] == "recent+match" for r in rows)
+
+
+# --- filtering: status + age ----------------------------------------------
+
+
+def test_archived_claim_excluded(store: KBStore) -> None:
+ cid = _approved_claim(store, "to be archived")
+ claim = store.get_claim(cid)
+ claim.status = ClaimStatus.ARCHIVED
+ store.update_claim(claim)
+ assert hot_mod.compute_hot_memory(store, limit=5) == []
+
+
+def test_superseded_claim_excluded(store: KBStore) -> None:
+ cid = _approved_claim(store, "to be superseded")
+ claim = store.get_claim(cid)
+ claim.status = ClaimStatus.SUPERSEDED
+ store.update_claim(claim)
+ assert hot_mod.compute_hot_memory(store, limit=5) == []
+
+
+def test_old_claim_filtered_by_max_age(store: KBStore) -> None:
+ cid = _approved_claim(store, "ancient history")
+ claim = store.get_claim(cid)
+ claim.updated_at = datetime.now(UTC) - timedelta(days=30)
+ store.update_claim(claim)
+ rows = hot_mod.compute_hot_memory(store, limit=5, max_age_seconds=24 * 3600)
+ assert rows == []
+
+
+# --- exclude_ids + cache --------------------------------------------------
+
+
+def test_exclude_ids_filters_caller_supplied(store: KBStore) -> None:
+ a = _approved_claim(store, "alpha")
+ b = _approved_claim(store, "beta")
+ rows = hot_mod.compute_hot_memory(store, limit=5, exclude_ids=[a])
+ ids = [r["id"] for r in rows]
+ assert a not in ids
+ assert b in ids
+
+
+def test_cache_returns_stale_within_ttl(store: KBStore) -> None:
+ _approved_claim(store, "first claim")
+ rows1 = hot_mod.compute_hot_memory(store, limit=5, now=1000.0)
+ _approved_claim(store, "second claim")
+ rows2 = hot_mod.compute_hot_memory(store, limit=5, now=1000.5)
+ assert len(rows1) == 1
+ assert len(rows2) == 1
+
+
+def test_cache_expires_after_ttl(store: KBStore) -> None:
+ _approved_claim(store, "first claim")
+ hot_mod.compute_hot_memory(store, limit=5, now=1000.0)
+ _approved_claim(store, "second claim")
+ rows = hot_mod.compute_hot_memory(store, limit=5, now=1100.0)
+ assert len(rows) == 2
+
+
+# --- attach_hot_memory shape contracts --------------------------------------
+
+
+def test_attach_to_dict_merges_meta(store: KBStore) -> None:
+ _approved_claim(store, "sky is blue")
+ result: dict = {"foo": "bar"}
+ wrapped = hot_mod.attach_hot_memory(result, store, query=None)
+ assert wrapped is result
+ assert "vouch_hot_memory" in wrapped["_meta"]
+ assert wrapped["foo"] == "bar"
+
+
+def test_attach_preserves_existing_meta(store: KBStore) -> None:
+ _approved_claim(store, "sky is blue")
+ result: dict = {"foo": "bar", "_meta": {"caller_meta": "keep me"}}
+ wrapped = hot_mod.attach_hot_memory(result, store, query=None)
+ assert wrapped["_meta"]["caller_meta"] == "keep me"
+ assert "vouch_hot_memory" in wrapped["_meta"]
+
+
+def test_attach_to_list_wraps_in_envelope(store: KBStore) -> None:
+ _approved_claim(store, "sky is blue")
+ result_list = [{"hit": 1}, {"hit": 2}]
+ wrapped = hot_mod.attach_hot_memory(result_list, store, query=None)
+ assert isinstance(wrapped, dict)
+ assert wrapped["items"] == result_list
+ assert "vouch_hot_memory" in wrapped["_meta"]
+
+
+def test_list_envelope_always_wraps_with_deprecation(store: KBStore) -> None:
+ result_list = [{"id": "x"}]
+ wrapped = hot_mod.attach_hot_memory(
+ result_list, store, query=None, list_envelope=True,
+ )
+ assert wrapped["items"] == result_list
+ assert "deprecation" in wrapped["_meta"]
+ assert wrapped["_meta"]["deprecation"]["migration"]
+
+
+def test_list_envelope_includes_sidebar_when_non_empty(store: KBStore) -> None:
+ _approved_claim(store, "fresh claim text")
+ wrapped = hot_mod.attach_hot_memory(
+ [], store, query=None, list_envelope=True,
+ )
+ assert wrapped["items"] == []
+ assert "vouch_hot_memory" in wrapped["_meta"]
+ assert "deprecation" in wrapped["_meta"]
+
+
+def test_attach_empty_sidebar_is_noop(store: KBStore) -> None:
+ result: dict = {"foo": "bar"}
+ out = hot_mod.attach_hot_memory(result, store, query=None)
+ assert out is result
+ assert "_meta" not in out
+
+
+def test_attach_scalar_unchanged(store: KBStore) -> None:
+ _approved_claim(store, "sky is blue")
+ assert hot_mod.attach_hot_memory("string", store, query=None) == "string"
+
+
+def test_compute_returns_empty_on_broken_store(tmp_path: Path) -> None:
+ class BrokenStore:
+ def __init__(self) -> None:
+ self.kb_dir = tmp_path / ".vouch"
+
+ def list_claims(self) -> list:
+ raise RuntimeError("simulated read failure")
+
+ rows = hot_mod.compute_hot_memory(BrokenStore(), limit=5) # type: ignore[arg-type]
+ assert rows == []
+
+
+# --- JSONL integration ------------------------------------------------------
+
+
+def test_jsonl_read_claim_attaches_hot_memory(
+ store: KBStore, monkeypatch: pytest.MonkeyPatch,
+) -> None:
+ cid_target = _approved_claim(store, "target claim about jwt rotation")
+ _approved_claim(store, "adjacent claim about jwt expiry")
+
+ monkeypatch.chdir(store.root)
+ result = handle_request(
+ {"id": "r1", "method": "kb.read_claim", "params": {"claim_id": cid_target}},
+ )
+ assert result["ok"] is True
+ payload = result["result"]
+ assert payload["id"] == cid_target
+ hot = payload["_meta"]["vouch_hot_memory"]
+ assert all(r["id"] != cid_target for r in hot)
+ assert any("jwt expiry" in r["text"] for r in hot)
+
+
+def test_jsonl_list_claims_uses_envelope(
+ store: KBStore, monkeypatch: pytest.MonkeyPatch,
+) -> None:
+ _approved_claim(store, "listed claim")
+ monkeypatch.chdir(store.root)
+ result = handle_request({"id": "r1", "method": "kb.list_claims", "params": {}})
+ assert result["ok"]
+ payload = result["result"]
+ assert "items" in payload
+ assert "deprecation" in payload["_meta"]
+ assert len(payload["items"]) == 1
+
+
+def test_jsonl_list_pending_recency_only_no_query_bias(
+ store: KBStore, monkeypatch: pytest.MonkeyPatch,
+) -> None:
+ src = store.put_source(b"e")
+ propose_claim(store, text="pending jwt topic", evidence=[src.id],
+ proposed_by="agent")
+ _approved_claim(store, "approved unrelated")
+ monkeypatch.chdir(store.root)
+ result = handle_request({"id": "r1", "method": "kb.list_pending", "params": {}})
+ assert result["ok"]
+ assert len(result["result"]["items"]) == 1
+ hot = result["result"]["_meta"].get("vouch_hot_memory", [])
+ assert len(hot) >= 1
+ assert all(r["why_hot"] == "recent" for r in hot)
+
+
+# --- universal coverage (#225 acceptance) -----------------------------------
+
+
+def test_hot_memory_universal_coverage() -> None:
+ """Every kb.* method either attaches hot memory or is explicitly excluded."""
+ covered = hot_mod.HOT_MEMORY_COVERED
+ excluded = set(hot_mod.HOT_MEMORY_EXCLUDED)
+ declared = set(METHODS)
+
+ assert covered <= declared
+ assert excluded <= declared
+ assert not covered & excluded
+
+ uncovered = declared - covered - excluded
+ assert not uncovered, (
+ f"methods missing from HOT_MEMORY_COVERED or HOT_MEMORY_EXCLUDED: "
+ f"{sorted(uncovered)}"
+ )
+
+
+@pytest.mark.parametrize("method", sorted(hot_mod.HOT_MEMORY_COVERED))
+def test_covered_methods_attach_sidebar_when_kb_has_recent_claims(
+ store: KBStore,
+ monkeypatch: pytest.MonkeyPatch,
+ method: str,
+) -> None:
+ """Smoke: each covered method returns vouch_hot_memory on a warm KB."""
+ cid = _approved_claim(store, "kafka stream processing policy")
+ _approved_claim(store, "adjacent kafka consumer group notes")
+ monkeypatch.chdir(store.root)
+
+ params: dict = {}
+ if method == "kb.search":
+ params = {"query": "xyzzy_no_index_hits", "limit": 3}
+ elif method == "kb.context":
+ params = {"task": "xyzzy_no_index_hits", "limit": 3}
+ elif method == "kb.read_page":
+ page = Page(id="pg-k", title="Kafka Guide", type=PageType.CONCEPT.value,
+ tags=["kafka"])
+ store.put_page(page)
+ params = {"page_id": "pg-k"}
+ elif method == "kb.read_claim":
+ params = {"claim_id": cid}
+ elif method == "kb.read_entity":
+ ent = Entity(id="ent-k", name="Kafka", type=EntityType.CONCEPT,
+ aliases=["Apache Kafka"])
+ store.put_entity(ent)
+ params = {"entity_id": "ent-k"}
+ elif method == "kb.read_relation":
+ ent = Entity(id="ent-a", name="A", type=EntityType.CONCEPT)
+ ent2 = Entity(id="ent-b", name="B", type=EntityType.CONCEPT)
+ store.put_entity(ent)
+ store.put_entity(ent2)
+ from vouch.models import Relation, RelationType
+ rel = Relation(id="rel-ab", source="ent-a", relation=RelationType.RELATES_TO,
+ target="ent-b")
+ store.put_relation(rel)
+ params = {"relation_id": "rel-ab"}
+
+ resp = handle_request({"id": "cov", "method": method, "params": params})
+ assert resp["ok"], resp
+ payload = resp["result"]
+ if method.startswith("kb.list_"):
+ assert "items" in payload
+ meta = payload["_meta"]
+ else:
+ meta = payload["_meta"]
+ assert "vouch_hot_memory" in meta
+ assert len(meta["vouch_hot_memory"]) >= 1
diff --git a/tests/test_jsonl_server.py b/tests/test_jsonl_server.py
index dc1aa0a7..e04286f6 100644
--- a/tests/test_jsonl_server.py
+++ b/tests/test_jsonl_server.py
@@ -88,7 +88,7 @@ def test_jsonl_dry_run_propose_then_real_propose(store: KBStore, monkeypatch) ->
assert real["ok"]
pending = handle_request({"id": "3", "method": "kb.list_pending",
"params": {}})
- assert len(pending["result"]) == 1
+ assert len(pending["result"]["items"]) == 1
def test_jsonl_full_flow(store: KBStore, monkeypatch) -> None:
diff --git a/tests/test_page_filters.py b/tests/test_page_filters.py
index 58983b0b..92c42c81 100644
--- a/tests/test_page_filters.py
+++ b/tests/test_page_filters.py
@@ -96,14 +96,16 @@ def test_jsonl_list_pages_filters(
from vouch.jsonl_server import HANDLERS
- rows = HANDLERS["kb.list_pages"]({"type": "followup", "meta": {"followup_status": "open"}})
+ rows = HANDLERS["kb.list_pages"](
+ {"type": "followup", "meta": {"followup_status": "open"}}
+ )["items"]
assert sorted(r["id"] for r in rows) == ["ping-alice-example", "renew-acme-example"]
- rows = HANDLERS["kb.list_pages"]({"meta_before": {"due_at": "2026-06-30"}})
+ rows = HANDLERS["kb.list_pages"]({"meta_before": {"due_at": "2026-06-30"}})["items"]
assert [r["id"] for r in rows] == ["ship-report"]
# no params -> unchanged full listing
- rows = HANDLERS["kb.list_pages"]({})
+ rows = HANDLERS["kb.list_pages"]({})["items"]
assert len(rows) == 4
@@ -115,7 +117,7 @@ def test_mcp_list_pages_filters(
from vouch import server
- rows = server.kb_list_pages(type="followup", meta={"followup_status": "open"})
+ rows = server.kb_list_pages(type="followup", meta={"followup_status": "open"})["items"]
assert sorted(r["id"] for r in rows) == ["ping-alice-example", "renew-acme-example"]
assert all("metadata" in r for r in rows)
From b16a6313140019fbf9a1295318b3c81a240f3210 Mon Sep 17 00:00:00 2001
From: plind-junior <59729252+plind-junior@users.noreply.github.com>
Date: Tue, 14 Jul 2026 12:21:12 +0900
Subject: [PATCH 05/12] docs(readme): answer the "i can do this with one
prompt" objection
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
the most common reaction to vouch is that a paragraph in claude.md or a
host's native auto-memory already solves it. that's largely true for
recall, and pretending otherwise loses the reader — so concede it up
front and draw the real line: memory is a recall problem, vouch is a
write-path trust problem.
adds a section after the llm-wiki framing with a side-by-side table
(who can write, why believe a line, what happens when it's wrong, who
changed it, n>=2 writers, what you end up reading) and an honest close:
solo, one prompt is fine; once several agents or a team write to shared
knowledge, the pile needs an editor, and an editor isn't promptable.
---
README.md | 19 +++++++++++++++++++
1 file changed, 19 insertions(+)
diff --git a/README.md b/README.md
index d6045694..ac8b1c3d 100644
--- a/README.md
+++ b/README.md
@@ -21,6 +21,25 @@
The destination is the one [Andrej Karpathy's llm-wiki idea file](https://gist.github.com/karpathy/442a6bf555914893e9891c11519de94f) sketches: stop using LLMs as search engines that rediscover your documents on every question — use them as tireless knowledge engineers that compile, cross-reference, and maintain a living wiki, while humans curate and think. vouch is that idea with the write path made trustworthy. `vouch compile` has an LLM draft the topic pages, but every page cites approved claims, every `[claim: …]` citation is machine-verified before the draft is filed, and the drafts pass through the same review gate as every other write. The LLM compiles; the human approves; the wiki compounds.
+## "I can do this with one prompt"
+
+Often true — and worth being honest about. If you want your agent to remember things, a paragraph in `CLAUDE.md`, a memory file, or your host's built-in auto-memory gets you most of the way, costs nothing, and needs no install. Reach for that first. Recall is not a hard problem.
+
+What is hard is *trust in the write path*, and that's a different problem than memory. Single-writer memory needs no trust model: you're the only author, and a bad line costs you a shrug. The moment writes come from **more than one author** — several agents, a teammate, a future you who forgot the context — the question stops being "what did we say?" and becomes "**who decided this was true, on what evidence, and can I audit it later?**" A prompt cannot answer that, no matter how good the prompt is.
+
+That's the whole of vouch:
+
+| | one prompt / memory file | vouch |
+|---|---|---|
+| **Who can write** | whatever the agent decides to save | agents *propose*; a human approves — nothing else lands |
+| **Why believe a line** | vibes | every claim cites a content-hashed source; uncited is a validation error |
+| **When it's wrong** | edit and hope | supersede / contradict / archive, with the old version still in history |
+| **Who changed it** | file mtime | append-only audit log: who proposed, who approved, citing what, when |
+| **At n ≥ 2 writers** | last write wins, silently | one gate, one reviewed history, shared by `git clone` |
+| **What you read** | a growing pile of notes | compiled topic pages with verified citations — a wiki, not a log |
+
+So the honest pitch: **vouch is not a better place to put memory — it's a review gate in front of one, and a wiki on the other side of it.** If you're solo and happy, one prompt is genuinely fine; vouch's session capture runs passively alongside whatever your host already remembers, rather than replacing it. But once a fleet of agents writes to shared knowledge — or a team does — that pile of notes needs an editor, and an editor is not something you can prompt your way to. The case in full: [docs/review-gate.md](docs/review-gate.md).
+
## Watch it work (110 seconds)
[](docs/vouch-how-it-works.mp4)
From 84c24fb2a1f44aefade73730bd7830679b6f1a5f Mon Sep 17 00:00:00 2001
From: plind-junior <59729252+plind-junior@users.noreply.github.com>
Date: Tue, 14 Jul 2026 13:15:27 +0900
Subject: [PATCH 06/12] docs(readme): add trust-vs-writers chart to the
one-prompt section
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
draws the section's core claim as a picture: trust in what you read
stays flat behind a review gate as writers are added, while a shared
memory file decays from the second writer on — identical at one writer,
which the section concedes in prose. light and dark svgs swap via a
prefers-color-scheme picture element; the subtitle labels the chart
illustrative, not a benchmark, and the six-row table above remains the
accessible table view of the same comparison.
---
README.md | 7 ++++
docs/img/trust-vs-writers-dark.svg | 56 +++++++++++++++++++++++++++++
docs/img/trust-vs-writers-light.svg | 56 +++++++++++++++++++++++++++++
3 files changed, 119 insertions(+)
create mode 100644 docs/img/trust-vs-writers-dark.svg
create mode 100644 docs/img/trust-vs-writers-light.svg
diff --git a/README.md b/README.md
index ac8b1c3d..a991b17c 100644
--- a/README.md
+++ b/README.md
@@ -38,6 +38,13 @@ That's the whole of vouch:
| **At n ≥ 2 writers** | last write wins, silently | one gate, one reviewed history, shared by `git clone` |
| **What you read** | a growing pile of notes | compiled topic pages with verified citations — a wiki, not a log |
+The same argument as a picture — at one writer the two are the same thing; the gap opens at the second writer and only widens:
+
+
+
+
+
+
So the honest pitch: **vouch is not a better place to put memory — it's a review gate in front of one, and a wiki on the other side of it.** If you're solo and happy, one prompt is genuinely fine; vouch's session capture runs passively alongside whatever your host already remembers, rather than replacing it. But once a fleet of agents writes to shared knowledge — or a team does — that pile of notes needs an editor, and an editor is not something you can prompt your way to. The case in full: [docs/review-gate.md](docs/review-gate.md).
## Watch it work (110 seconds)
diff --git a/docs/img/trust-vs-writers-dark.svg b/docs/img/trust-vs-writers-dark.svg
new file mode 100644
index 00000000..1575be33
--- /dev/null
+++ b/docs/img/trust-vs-writers-dark.svg
@@ -0,0 +1,56 @@
+
+ Trust in what you read, as writers are added — identical at one writer; a memory file decays past two writers, a review gate holds.
+
+
+
+ Trust in what you read, as writers are added
+ illustrative — the shape of the argument, not a benchmark
+
+
+
+ vouch (review gate)
+
+ one prompt / memory file
+
+
+
+
+ high
+ low
+
+
+ 1
+ 2
+ 3
+ 4
+ 5+
+ writers sharing the knowledge (agents, teammates, future you)
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ one writer: the two are identical
+
+ n ≥ 2: writes now need a trust model
+
+
+ vouch (review gate)
+ every landed write reviewed
+ one prompt / memory file
+ last write wins, silently
+
+
+
diff --git a/docs/img/trust-vs-writers-light.svg b/docs/img/trust-vs-writers-light.svg
new file mode 100644
index 00000000..f4c9ec89
--- /dev/null
+++ b/docs/img/trust-vs-writers-light.svg
@@ -0,0 +1,56 @@
+
+ Trust in what you read, as writers are added — identical at one writer; a memory file decays past two writers, a review gate holds.
+
+
+
+ Trust in what you read, as writers are added
+ illustrative — the shape of the argument, not a benchmark
+
+
+
+ vouch (review gate)
+
+ one prompt / memory file
+
+
+
+
+ high
+ low
+
+
+ 1
+ 2
+ 3
+ 4
+ 5+
+ writers sharing the knowledge (agents, teammates, future you)
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ one writer: the two are identical
+
+ n ≥ 2: writes now need a trust model
+
+
+ vouch (review gate)
+ every landed write reviewed
+ one prompt / memory file
+ last write wins, silently
+
+
+
From 5c79374339f4ec2ce8495a784360a3f109217c9a Mon Sep 17 00:00:00 2001
From: plind-junior <59729252+plind-junior@users.noreply.github.com>
Date: Wed, 15 Jul 2026 12:35:14 +0900
Subject: [PATCH 07/12] fix(gate): bind the proposal/audit actor to the
authenticated subject
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
step 0.1 of the gate-integrity hotfix. the actor recorded on every proposal
and audit event came from client-controlled input: X-Vouch-Agent on /rpc
(jsonl_server._agent) and VOUCH_AGENT env on /mcp (server._agent). because the
self-approval gate compares actor strings, one bearer token could propose as
"alice" and approve as "bob" and the approval sailed through — the review gate
was forgeable by anyone holding a single token.
both _agent() resolvers now prefer trust.current().auth_subject when the
request is authenticated, returning a stable token: identity that the
client cannot override. the header/env fallback survives only for tokenless
loopback/dev, which is trusted by design. mirrors web/server.py, where the
token's label already wins over any client-supplied name.
the exploit is pinned by a test: propose-as-alice then approve-as-bob under
one token is now caught as forbidden_self_approval. /mcp callers also stop
collapsing to a single env identity — distinct tokens are distinct actors, so
cross-review becomes possible and attribution is real.
---
src/vouch/jsonl_server.py | 8 ++++
src/vouch/server.py | 7 ++++
tests/test_http_server_mcp.py | 26 +++++++++++++
tests/test_jsonl_server.py | 69 +++++++++++++++++++++++++++++++++++
4 files changed, 110 insertions(+)
diff --git a/src/vouch/jsonl_server.py b/src/vouch/jsonl_server.py
index 6e2fc6ab..d63ea354 100644
--- a/src/vouch/jsonl_server.py
+++ b/src/vouch/jsonl_server.py
@@ -84,6 +84,14 @@ def _store() -> KBStore:
def _agent() -> str:
+ # An authenticated bearer subject is the principal's real identity; it must
+ # win over the client-supplied X-Vouch-Agent header (and VOUCH_AGENT env),
+ # or one token could propose as one actor and approve as another to defeat
+ # the self-approval gate. Only fall back to the header/env when the request
+ # is unauthenticated (tokenless loopback/dev), which is trusted by design.
+ subject = trust_mod.current().auth_subject
+ if subject is not None:
+ return f"token:{subject}"
return _actor.get() or os.environ.get("VOUCH_AGENT", "unknown-agent")
diff --git a/src/vouch/server.py b/src/vouch/server.py
index b78c7ca1..26db4bfd 100644
--- a/src/vouch/server.py
+++ b/src/vouch/server.py
@@ -71,6 +71,13 @@ def _store() -> KBStore:
def _agent() -> str:
+ # An authenticated bearer subject (set by the /mcp transport) is the
+ # principal's real identity and must be what proposals/audit attribute to,
+ # so a token cannot be spoofed and distinct tokens are distinct actors.
+ # VOUCH_AGENT is only the tokenless (stdio/dev) fallback.
+ subject = trust_mod.current().auth_subject
+ if subject is not None:
+ return f"token:{subject}"
return os.environ.get("VOUCH_AGENT", "unknown-agent")
diff --git a/tests/test_http_server_mcp.py b/tests/test_http_server_mcp.py
index 7e58fcb8..726d3f90 100644
--- a/tests/test_http_server_mcp.py
+++ b/tests/test_http_server_mcp.py
@@ -339,3 +339,29 @@ def test_legacy_capabilities_unchanged(base_url: str) -> None:
code, body = _get(base_url, "/capabilities")
assert code == 200
assert "http" in body["transports"]
+
+
+def test_mcp_agent_binds_to_auth_subject(monkeypatch) -> None:
+ """On /mcp the tool actor must bind to the authenticated bearer subject.
+
+ server._agent read only VOUCH_AGENT, so every authenticated caller
+ collapsed to one env-set identity and the token was never the attribution.
+ """
+ from vouch import server as vouch_server
+ from vouch import trust as trust_mod
+
+ monkeypatch.setenv("VOUCH_AGENT", "env-agent")
+ trust = trust_mod.with_auth_subject(trust_mod.MCP_HTTP, "mcp-token")
+ with trust_mod.trust_context(trust):
+ actor = vouch_server._agent()
+ assert actor != "env-agent"
+ assert trust_mod.auth_subject_for_token("mcp-token") in actor
+
+
+def test_mcp_agent_falls_back_to_env_when_unauthenticated(monkeypatch) -> None:
+ from vouch import server as vouch_server
+ from vouch import trust as trust_mod
+
+ monkeypatch.setenv("VOUCH_AGENT", "env-agent")
+ with trust_mod.trust_context(trust_mod.MCP_STDIO): # no auth_subject
+ assert vouch_server._agent() == "env-agent"
diff --git a/tests/test_jsonl_server.py b/tests/test_jsonl_server.py
index e04286f6..75cecb8c 100644
--- a/tests/test_jsonl_server.py
+++ b/tests/test_jsonl_server.py
@@ -248,3 +248,72 @@ def _boom(_params: dict) -> dict:
assert resp["ok"] is False
assert resp["error"]["code"] == "internal_error"
assert "traceback" not in resp["error"]
+
+
+# --- gate integrity: the actor binds to the authenticated principal --------
+
+
+def test_jsonl_agent_binds_to_auth_subject_over_header() -> None:
+ """An authenticated request's actor is the token subject, not the header.
+
+ X-Vouch-Agent is client-supplied; it must never override an authenticated
+ identity, or one token could masquerade as any actor it likes.
+ """
+ from vouch import jsonl_server
+ from vouch import trust as trust_mod
+
+ trust = trust_mod.with_auth_subject(trust_mod.JSONL_HTTP, "one-token")
+ with trust_mod.trust_context(trust):
+ reset = jsonl_server._actor.set("spoofed-header")
+ try:
+ actor = jsonl_server._agent()
+ finally:
+ jsonl_server._actor.reset(reset)
+ assert "spoofed-header" not in actor
+ assert trust_mod.auth_subject_for_token("one-token") in actor
+
+
+def test_jsonl_agent_uses_header_when_unauthenticated() -> None:
+ """Tokenless (dev/loopback) requests keep the existing header attribution."""
+ from vouch import jsonl_server
+ from vouch import trust as trust_mod
+
+ with trust_mod.trust_context(trust_mod.JSONL_HTTP): # no auth_subject
+ reset = jsonl_server._actor.set("named-agent")
+ try:
+ actor = jsonl_server._agent()
+ finally:
+ jsonl_server._actor.reset(reset)
+ assert actor == "named-agent"
+
+
+def test_one_token_cannot_self_approve_by_swapping_header(
+ store: KBStore, monkeypatch
+) -> None:
+ """The core exploit is closed: propose-as-alice, approve-as-bob, one token.
+
+ Because the actor binds to the authenticated subject, both operations
+ resolve to the same actor and the self-approval gate blocks the approval.
+ """
+ from vouch import jsonl_server
+ from vouch import trust as trust_mod
+
+ src = store.put_source(b"evidence")
+ monkeypatch.chdir(store.root)
+ trust = trust_mod.with_auth_subject(trust_mod.JSONL_HTTP, "one-token")
+ with trust_mod.trust_context(trust):
+ reset = jsonl_server._actor.set("alice")
+ try:
+ pr = handle_request({"id": "1", "method": "kb.propose_claim",
+ "params": {"text": "c", "evidence": [src.id]}})
+ finally:
+ jsonl_server._actor.reset(reset)
+ pid = pr["result"]["proposal_id"]
+ reset = jsonl_server._actor.set("bob")
+ try:
+ resp = handle_request({"id": "2", "method": "kb.approve",
+ "params": {"proposal_id": pid}})
+ finally:
+ jsonl_server._actor.reset(reset)
+ assert not resp["ok"]
+ assert "forbidden_self_approval" in resp["error"]["message"]
From f7887d1a8baa426e13cfe65ce784457ebbce879a Mon Sep 17 00:00:00 2001
From: plind-junior <59729252+plind-junior@users.noreply.github.com>
Date: Wed, 15 Jul 2026 12:44:01 +0900
Subject: [PATCH 08/12] fix(gate): close the import_apply bypass past the
review gate
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
step 0.2 of the gate-integrity hotfix. kb.import_apply writes bundle members
(claims/pages/decided) straight to disk — a parallel path past
proposals.approve() — and it was reachable by agents on MCP and JSONL. two
stopgaps until gated import lands (roadmap 8.2):
- import_check now refuses any bundle carrying decided/ members, scanned
directly from the manifest (not a self-reported safety flag a hand-crafted
bundle could lie about), so import_apply raises on them for every caller
including the CLI. decided/ holds approved decisions; importing it would land
approved claims/pages with no receiving-side proposal.
- kb.import_apply is dropped from the agent surfaces — the MCP tool, the JSONL
handler + HANDLERS entry, and the METHODS list — and from hot_memory's
exclusion map. it survives only as the human `vouch import apply` CLI
command. the read-only kb.import_check stays available on every surface.
exports still include decided/ (a faithful snapshot); the guard is on the
write side. capabilities/handlers parity holds.
---
src/vouch/bundle.py | 12 ++++++++++++
src/vouch/capabilities.py | 3 ++-
src/vouch/hot_memory.py | 1 -
src/vouch/jsonl_server.py | 13 +++++--------
src/vouch/server.py | 15 ++++-----------
tests/test_bundle.py | 30 ++++++++++++++++++++++++++++++
tests/test_capabilities.py | 19 +++++++++++++++++++
7 files changed, 72 insertions(+), 21 deletions(-)
diff --git a/src/vouch/bundle.py b/src/vouch/bundle.py
index 51e2f0d9..18c83cdb 100644
--- a/src/vouch/bundle.py
+++ b/src/vouch/bundle.py
@@ -519,6 +519,18 @@ def import_check(kb_dir: Path, bundle_path: Path) -> ImportCheckResult:
issues.extend(_manifest_safety_issues(manifest))
recorded = {f["path"]: f for f in manifest["files"]}
manifest_paths = set(recorded)
+ # decided/ holds approved decisions; import writes members straight to
+ # disk, so a bundle carrying decided/ would land approved claims/pages
+ # without a receiving-side proposal — a write past the review gate.
+ # Refuse until gated import exists (roadmap 8.2). Scanned directly from
+ # the manifest rather than via a self-reported safety flag, which a
+ # hand-crafted bundle could lie about.
+ decided_members = sorted(p for p in manifest_paths if p.startswith("decided/"))
+ if decided_members:
+ issues.append(
+ "bundle carries decided/ members that cannot be imported past "
+ f"the review gate: {decided_members[0]}"
+ )
for f in manifest["files"]:
try:
dest = _safe_member_path(kb_dir, f["path"])
diff --git a/src/vouch/capabilities.py b/src/vouch/capabilities.py
index 9d50f305..84901e74 100644
--- a/src/vouch/capabilities.py
+++ b/src/vouch/capabilities.py
@@ -82,7 +82,8 @@
"kb.export",
"kb.export_check",
"kb.import_check",
- "kb.import_apply",
+ # kb.import_apply intentionally absent — it writes past the review gate, so
+ # it is a human-only CLI command, not an agent method (roadmap step 0.2).
"kb.audit",
"kb.reindex_embeddings",
"kb.dedup_scan",
diff --git a/src/vouch/hot_memory.py b/src/vouch/hot_memory.py
index b6eae608..fea42ea1 100644
--- a/src/vouch/hot_memory.py
+++ b/src/vouch/hot_memory.py
@@ -184,7 +184,6 @@ def mark_volunteered(session_id: str, claim_id: str, *, pushed_at: float) -> Non
"kb.export": "bundle write — not a read response",
"kb.export_check": "preflight — no claim sidebar needed",
"kb.import_check": "preflight — no claim sidebar needed",
- "kb.import_apply": "mutation — applies bundle",
"kb.audit": "event log — different shape from claim reads",
"kb.reindex_embeddings": "maintenance — mutates derived index",
"kb.dedup_scan": "analysis — not a standard read",
diff --git a/src/vouch/jsonl_server.py b/src/vouch/jsonl_server.py
index d63ea354..836d13ba 100644
--- a/src/vouch/jsonl_server.py
+++ b/src/vouch/jsonl_server.py
@@ -733,13 +733,11 @@ def _h_import_check(p: dict) -> dict:
"identical_files": len(r.identical), "issues": r.issues}
-def _h_import_apply(p: dict) -> dict:
- r = bundle.import_apply(
- _store().kb_dir, Path(p["bundle_path"]),
- on_conflict=p.get("on_conflict", "skip"), actor=_agent(),
- )
- health.rebuild_index(_store())
- return r
+# kb.import_apply is deliberately NOT an agent-facing handler: it writes bundle
+# members (claims/pages/decided) straight to disk, a parallel path past
+# proposals.approve(). It survives only as the human `vouch import apply` CLI
+# command until gated import lands (roadmap 8.2). kb.import_check (read-only)
+# stays available to agents.
def _h_audit(p: dict) -> dict:
@@ -948,7 +946,6 @@ def _h_propose_theme(p: dict) -> dict:
"kb.export": _h_export,
"kb.export_check": _h_export_check,
"kb.import_check": _h_import_check,
- "kb.import_apply": _h_import_apply,
"kb.audit": _h_audit,
"kb.reindex_embeddings": _h_reindex_embeddings,
"kb.dedup_scan": _h_dedup_scan,
diff --git a/src/vouch/server.py b/src/vouch/server.py
index 26db4bfd..15d473ed 100644
--- a/src/vouch/server.py
+++ b/src/vouch/server.py
@@ -1064,17 +1064,10 @@ def kb_import_check(bundle_path: str) -> dict[str, Any]:
}
-@mcp.tool()
-def kb_import_apply(bundle_path: str, on_conflict: str = "skip") -> dict[str, Any]:
- try:
- r = bundle.import_apply(
- _store().kb_dir, Path(bundle_path),
- on_conflict=on_conflict, actor=_agent(),
- )
- except (RuntimeError, ValueError) as e:
- raise ValueError(str(e)) from e
- health.rebuild_index(_store())
- return r
+# kb_import_apply is intentionally not exposed as an MCP tool: applying a bundle
+# writes members (claims/pages/decided) straight to disk, bypassing
+# proposals.approve(). It remains the human-only `vouch import apply` CLI command
+# until gated import lands (roadmap 8.2). kb_import_check (read-only) stays.
@mcp.tool()
diff --git a/tests/test_bundle.py b/tests/test_bundle.py
index 391cdc6c..a15291ef 100644
--- a/tests/test_bundle.py
+++ b/tests/test_bundle.py
@@ -45,6 +45,36 @@ def test_export_import_round_trip(store: KBStore, tmp_path: Path) -> None:
assert dest.get_claim("c1").text == "alpha"
+def test_import_rejects_bundle_carrying_decided_members(
+ store: KBStore, tmp_path: Path
+) -> None:
+ """A bundle with decided/ members is a write past the review gate.
+
+ decided/ holds approved decisions; import writes members straight to disk,
+ so importing decided/ lands approved claims/pages without a receiving-side
+ proposal. Until gated import exists (roadmap 8.2), any such bundle is
+ refused — by import_check (issue) and therefore import_apply (raises).
+ """
+ from vouch.proposals import approve, propose_claim
+
+ src = store.put_source(b"e", title="d")
+ pid = propose_claim(
+ store, text="alpha", evidence=[src.id], proposed_by="a"
+ ).id
+ approve(store, pid, approved_by="b")
+ assert any((store.kb_dir / "decided").glob("*")), "decided/ should be populated"
+
+ bundle_path = tmp_path / "out.tar.gz"
+ bundle.export(store.kb_dir, dest=bundle_path)
+
+ dest = KBStore.init(tmp_path / "dest")
+ diff = bundle.import_check(dest.kb_dir, bundle_path)
+ assert not diff.ok
+ assert any("decided" in issue for issue in diff.issues)
+ with pytest.raises(RuntimeError):
+ bundle.import_apply(dest.kb_dir, bundle_path)
+
+
def test_manifest_paths_match_tar_member_names(store: KBStore, tmp_path: Path) -> None:
# Regression for the Windows path-separator bug: when build_manifest used
# str(rel) the manifest stored "sources\\meta.yaml" while the tar
diff --git a/tests/test_capabilities.py b/tests/test_capabilities.py
index 4d4dcc27..a92aa7a4 100644
--- a/tests/test_capabilities.py
+++ b/tests/test_capabilities.py
@@ -11,6 +11,25 @@
from vouch.jsonl_server import HANDLERS
+def test_import_apply_is_not_on_agent_surfaces() -> None:
+ """kb.import_apply writes past the review gate, so agents must not reach it.
+
+ It stays a human-only CLI command; the read-only kb.import_check survives
+ on every surface. (The real fix is gated import — roadmap 8.2.)
+ """
+ from vouch.jsonl_server import HANDLERS
+
+ assert "kb.import_apply" not in HANDLERS
+ assert "kb.import_apply" not in set(capabilities.capabilities().methods)
+ # read-only diff stays available to agents
+ assert "kb.import_check" in HANDLERS
+
+ from vouch.server import mcp
+
+ assert mcp._tool_manager.get_tool("kb_import_apply") is None
+ assert mcp._tool_manager.get_tool("kb_import_check") is not None
+
+
def test_capabilities_matches_jsonl_handlers() -> None:
caps = capabilities.capabilities()
declared = set(caps.methods)
From a56da0bf26feee5c9271c9a12867b050191637f2 Mon Sep 17 00:00:00 2001
From: plind-junior <59729252+plind-junior@users.noreply.github.com>
Date: Wed, 15 Jul 2026 12:52:50 +0900
Subject: [PATCH 09/12] fix(gate): fence export/import paths to the project
root on remote surfaces
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
step 0.4 of the gate-integrity hotfix. kb.export took a client-supplied
out_path and wrote a tarball there (creating parent dirs), and
kb.import_check/export_check read a client-supplied bundle_path — so a remote
caller on /rpc or /mcp could clobber or read an arbitrary file
(out_path=../../etc/cron.d/x, bundle_path=/etc/passwd).
new KBStore.resolve_under_root is the containment half of read_under_root
(resolve + is_relative_to, no open), and bundle.fenced_bundle_path applies it
only when trust.current().remote — so both server surfaces confine the path to
the project root while local cli and stdio stay unfenced, since a human
choosing where to write a backup is not a threat. wired into _h_export /
_h_export_check / _h_import_check and the matching mcp tools, one shared helper
so the surfaces cannot drift.
tests pin both directions: a remote ../escaped.tar.gz export is refused and
writes nothing, a within-root export still works, and a remote import_check at
/etc/passwd is refused instead of reading it.
---
src/vouch/bundle.py | 19 +++++++++++++++++-
src/vouch/jsonl_server.py | 12 +++++++-----
src/vouch/server.py | 11 +++++++----
src/vouch/storage.py | 17 +++++++++++++++++
tests/test_http_server_mcp.py | 16 ++++++++++++++++
tests/test_jsonl_server.py | 36 +++++++++++++++++++++++++++++++++++
6 files changed, 101 insertions(+), 10 deletions(-)
diff --git a/src/vouch/bundle.py b/src/vouch/bundle.py
index 18c83cdb..329fc874 100644
--- a/src/vouch/bundle.py
+++ b/src/vouch/bundle.py
@@ -29,7 +29,7 @@
from . import audit
from .models import Claim, Entity, Evidence, Proposal, Relation, Session, Source
-from .storage import _deserialize_page, sha256_hex
+from .storage import KBStore, _deserialize_page, sha256_hex
MANIFEST_NAME = "manifest.json"
SPEC_VERSION = "vouch-bundle-0.1"
@@ -115,6 +115,23 @@ def build_manifest(kb_dir: Path) -> dict[str, Any]:
}
+def fenced_bundle_path(store: KBStore, raw: str) -> Path:
+ """A client-supplied bundle/export path, confined to the project root on
+ remote surfaces.
+
+ Export writes to the path and import reads from it, so an unfenced path on
+ /rpc or /mcp is a remote arbitrary-file clobber (export) or read (import).
+ On remote trust the path is resolved and contained to the project root;
+ local CLI and stdio are unfenced by design, since a human choosing where to
+ write a backup is not a threat.
+ """
+ from . import trust
+
+ if trust.current().remote:
+ return store.resolve_under_root(raw)
+ return Path(raw)
+
+
def export(kb_dir: Path, *, dest: Path, actor: str = "vouch-export") -> dict[str, Any]:
manifest = build_manifest(kb_dir)
dest.parent.mkdir(parents=True, exist_ok=True)
diff --git a/src/vouch/jsonl_server.py b/src/vouch/jsonl_server.py
index 836d13ba..5d22c902 100644
--- a/src/vouch/jsonl_server.py
+++ b/src/vouch/jsonl_server.py
@@ -714,20 +714,23 @@ def _h_doctor(_: dict) -> dict:
def _h_export(p: dict) -> dict:
- manifest = bundle.export(_store().kb_dir, dest=Path(p["out_path"]),
- actor=_agent())
+ s = _store()
+ dest = bundle.fenced_bundle_path(s, p["out_path"])
+ manifest = bundle.export(s.kb_dir, dest=dest, actor=_agent())
return {"bundle_id": manifest["bundle_id"],
"files": len(manifest["files"]), "out": p["out_path"]}
def _h_export_check(p: dict) -> dict:
- r = bundle.export_check(Path(p["bundle_path"]))
+ s = _store()
+ r = bundle.export_check(bundle.fenced_bundle_path(s, p["bundle_path"]))
return {"ok": r.ok, "bundle_id": r.bundle_id,
"files_checked": r.files_checked, "issues": r.issues}
def _h_import_check(p: dict) -> dict:
- r = bundle.import_check(_store().kb_dir, Path(p["bundle_path"]))
+ s = _store()
+ r = bundle.import_check(s.kb_dir, bundle.fenced_bundle_path(s, p["bundle_path"]))
return {"ok": r.ok, "bundle_id": r.bundle_id,
"new_files": r.new_files, "conflicts": r.conflicts,
"identical_files": len(r.identical), "issues": r.issues}
@@ -771,7 +774,6 @@ def _h_dedup_scan(p: dict) -> dict:
def _h_eval_embeddings(p: dict) -> dict:
- from pathlib import Path
from .embeddings.scorer import evaluate
return evaluate(
diff --git a/src/vouch/server.py b/src/vouch/server.py
index 15d473ed..de8ea050 100644
--- a/src/vouch/server.py
+++ b/src/vouch/server.py
@@ -1037,7 +1037,9 @@ def kb_doctor() -> dict[str, Any]:
@mcp.tool()
def kb_export(out_path: str) -> dict[str, Any]:
- manifest = bundle.export(_store().kb_dir, dest=Path(out_path), actor=_agent())
+ s = _store()
+ dest = bundle.fenced_bundle_path(s, out_path)
+ manifest = bundle.export(s.kb_dir, dest=dest, actor=_agent())
return {
"bundle_id": manifest["bundle_id"],
"files": len(manifest["files"]),
@@ -1047,7 +1049,8 @@ def kb_export(out_path: str) -> dict[str, Any]:
@mcp.tool()
def kb_export_check(bundle_path: str) -> dict[str, Any]:
- r = bundle.export_check(Path(bundle_path))
+ s = _store()
+ r = bundle.export_check(bundle.fenced_bundle_path(s, bundle_path))
return {
"ok": r.ok, "bundle_id": r.bundle_id,
"files_checked": r.files_checked, "issues": r.issues,
@@ -1056,7 +1059,8 @@ def kb_export_check(bundle_path: str) -> dict[str, Any]:
@mcp.tool()
def kb_import_check(bundle_path: str) -> dict[str, Any]:
- r = bundle.import_check(_store().kb_dir, Path(bundle_path))
+ s = _store()
+ r = bundle.import_check(s.kb_dir, bundle.fenced_bundle_path(s, bundle_path))
return {
"ok": r.ok, "bundle_id": r.bundle_id,
"new_files": r.new_files, "conflicts": r.conflicts,
@@ -1119,7 +1123,6 @@ def kb_dedup_scan(
@mcp.tool()
def kb_eval_embeddings(*, queries_path: str, k: int = 10) -> dict[str, Any]:
"""Run retrieval eval over a JSONL queries file."""
- from pathlib import Path
from .embeddings.scorer import evaluate
store = _store()
diff --git a/src/vouch/storage.py b/src/vouch/storage.py
index b2214d7b..37adbe6c 100644
--- a/src/vouch/storage.py
+++ b/src/vouch/storage.py
@@ -262,6 +262,23 @@ def read_under_root(self, path: str | Path) -> tuple[Path, bytes]:
with os.fdopen(fd, "rb") as f:
return resolved, f.read()
+ def resolve_under_root(self, path: str | Path) -> Path:
+ """Resolve ``path`` and confirm it is inside the project root, or raise.
+
+ The containment half of :meth:`read_under_root`, without opening — for
+ a client-supplied path that will be written (a bundle export
+ destination) or read as a whole file. Pre-existing symlinks are
+ resolved first, then the result is checked for containment; a read that
+ needs the stronger O_NOFOLLOW / regular-file guarantees should use
+ :meth:`read_under_root`.
+ """
+ resolved = Path(path).resolve()
+ if not resolved.is_relative_to(self.root):
+ raise ValueError(
+ f"path must be inside project root ({self.root}): {resolved}"
+ )
+ return resolved
+
# --- bootstrap ---------------------------------------------------------
@classmethod
diff --git a/tests/test_http_server_mcp.py b/tests/test_http_server_mcp.py
index 726d3f90..2552265e 100644
--- a/tests/test_http_server_mcp.py
+++ b/tests/test_http_server_mcp.py
@@ -365,3 +365,19 @@ def test_mcp_agent_falls_back_to_env_when_unauthenticated(monkeypatch) -> None:
monkeypatch.setenv("VOUCH_AGENT", "env-agent")
with trust_mod.trust_context(trust_mod.MCP_STDIO): # no auth_subject
assert vouch_server._agent() == "env-agent"
+
+
+def test_mcp_export_fenced_to_root_on_remote(tmp_path, monkeypatch) -> None:
+ """The /mcp export tool cannot clobber a file outside the project root."""
+ from vouch import server as vouch_server
+ from vouch import trust as trust_mod
+
+ store = KBStore.init(tmp_path)
+ monkeypatch.chdir(store.root)
+ tool = vouch_server.mcp._tool_manager.get_tool("kb_export")
+ assert tool is not None
+ with trust_mod.trust_context(trust_mod.MCP_HTTP), pytest.raises(
+ ValueError, match="project root"
+ ):
+ tool.fn(out_path="../escaped.tar.gz")
+ assert not (store.root.parent / "escaped.tar.gz").exists()
diff --git a/tests/test_jsonl_server.py b/tests/test_jsonl_server.py
index 75cecb8c..a2dfaada 100644
--- a/tests/test_jsonl_server.py
+++ b/tests/test_jsonl_server.py
@@ -317,3 +317,39 @@ def test_one_token_cannot_self_approve_by_swapping_header(
jsonl_server._actor.reset(reset)
assert not resp["ok"]
assert "forbidden_self_approval" in resp["error"]["message"]
+
+
+def test_export_out_path_fenced_to_root_on_remote(store: KBStore, monkeypatch) -> None:
+ """A remote caller cannot make kb.export clobber a file outside the root."""
+ from vouch import trust as trust_mod
+
+ monkeypatch.chdir(store.root)
+ with trust_mod.trust_context(trust_mod.JSONL_HTTP):
+ resp = handle_request({"id": "1", "method": "kb.export",
+ "params": {"out_path": "../escaped.tar.gz"}})
+ assert not resp["ok"]
+ assert "project root" in resp["error"]["message"]
+ assert not (store.root.parent / "escaped.tar.gz").exists()
+
+
+def test_export_within_root_still_works_on_remote(store: KBStore, monkeypatch) -> None:
+ from vouch import trust as trust_mod
+
+ monkeypatch.chdir(store.root)
+ with trust_mod.trust_context(trust_mod.JSONL_HTTP):
+ resp = handle_request({"id": "1", "method": "kb.export",
+ "params": {"out_path": "bundle.tar.gz"}})
+ assert resp["ok"]
+ assert (store.root / "bundle.tar.gz").exists()
+
+
+def test_import_check_bundle_path_fenced_on_remote(store: KBStore, monkeypatch) -> None:
+ """A remote caller cannot point kb.import_check at an arbitrary file."""
+ from vouch import trust as trust_mod
+
+ monkeypatch.chdir(store.root)
+ with trust_mod.trust_context(trust_mod.JSONL_HTTP):
+ resp = handle_request({"id": "1", "method": "kb.import_check",
+ "params": {"bundle_path": "/etc/passwd"}})
+ assert not resp["ok"]
+ assert "project root" in resp["error"]["message"]
From a4ed4a8f8927430e9087af7e17656ca49711dd0a Mon Sep 17 00:00:00 2001
From: plind-junior <59729252+plind-junior@users.noreply.github.com>
Date: Wed, 15 Jul 2026 12:58:38 +0900
Subject: [PATCH 10/12] fix(gate): allowlist console /proxy targets to loopback
or configured origins
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
step 0.3 of the gate-integrity hotfix. the console /proxy bridge forwarded to
whatever host X-Vouch-Target named and copied the caller's Authorization header
onto the forwarded request — an open relay that leaks the bearer token to an
arbitrary host and an SSRF primitive into internal services (including cloud
metadata endpoints). the loopback client-guard limited who could call it, not
where it forwarded.
build_console_app now takes allowed_targets (serve origins, scheme://host:port)
and _target_allowed enforces it: with an allowlist the target origin must be
one of the configured origins; with none, the target must be loopback — the
safe default for the local console, never an arbitrary host. serve_console
threads the allowlist through. existing loopback-upstream proxy tests are
unaffected; a non-loopback target is now refused with forbidden_target.
---
src/vouch/web/console.py | 56 +++++++++++++++++++++++++++++++++++++---
tests/test_console.py | 31 ++++++++++++++++++++++
2 files changed, 83 insertions(+), 4 deletions(-)
diff --git a/src/vouch/web/console.py b/src/vouch/web/console.py
index 7b5fa8a9..8d768d40 100644
--- a/src/vouch/web/console.py
+++ b/src/vouch/web/console.py
@@ -73,14 +73,49 @@ def _err(status: int, code: str, message: str) -> JSONResponse:
)
-def build_console_app(console_dir: Path, *, allow_remote: bool = False) -> Starlette:
+# Target hosts the bridge may forward to when no allowlist is configured. The
+# bridge copies the caller's Authorization header onto the forwarded request,
+# so an unrestricted target turns the console into a token-forwarding SSRF
+# relay to any host the server can reach (including cloud metadata endpoints).
+_LOOPBACK_TARGET_HOSTS = frozenset({"127.0.0.1", "::1", "localhost"})
+
+
+def _normalize_origin(raw: str) -> str:
+ p = urllib.parse.urlparse(raw)
+ return f"{p.scheme}://{p.netloc}".lower()
+
+
+def _target_allowed(
+ parsed: urllib.parse.ParseResult, allowed_origins: frozenset[str]
+) -> bool:
+ """Whether the bridge may forward to ``parsed``.
+
+ With a configured allowlist the target's origin (scheme://host:port) must
+ be one of the configured serve origins. With no allowlist the target must
+ be loopback — the safe default for the local console, and never an
+ arbitrary host the auth header could be leaked to.
+ """
+ if allowed_origins:
+ return f"{parsed.scheme}://{parsed.netloc}".lower() in allowed_origins
+ return (parsed.hostname or "").lower() in _LOOPBACK_TARGET_HOSTS
+
+
+def build_console_app(
+ console_dir: Path,
+ *,
+ allow_remote: bool = False,
+ allowed_targets: tuple[str, ...] = (),
+) -> Starlette:
"""Build the ASGI app: the ``/proxy/*`` bridge + the static SPA.
``allow_remote`` drops the loopback guard on the bridge — only for
- deliberately-exposed deployments behind their own auth.
+ deliberately-exposed deployments behind their own auth. ``allowed_targets``
+ is the set of serve origins (scheme://host:port) the bridge may forward to;
+ empty means loopback-only.
"""
root = console_dir.resolve()
index = root / "index.html"
+ allowed_origins = frozenset(_normalize_origin(t) for t in allowed_targets if t)
async def _proxy(request: Request) -> Response:
client_host = request.client.host if request.client else None
@@ -93,6 +128,12 @@ async def _proxy(request: Request) -> Response:
parsed = urllib.parse.urlparse(target_raw)
if parsed.scheme not in ("http", "https") or not parsed.netloc:
return _err(400, "bad_target", f"not a valid http(s) target: {target_raw}")
+ if not _target_allowed(parsed, allowed_origins):
+ return _err(
+ 403, "forbidden_target",
+ f"target host not allowed: {target_raw} — the bridge forwards "
+ "only to the configured serve origin(s), or loopback by default",
+ )
# The path after /proxy is appended to the target's host:port; any path
# on the target itself is dropped, matching vouch-proxy.ts exactly.
@@ -157,10 +198,15 @@ def serve_console(
host: str = "127.0.0.1",
port: int = 5173,
allow_remote: bool = False,
+ allowed_targets: tuple[str, ...] = (),
console_dir: Path | None = None,
) -> None:
"""Serve the console with uvicorn (blocks). Raises ``ConsoleError`` early
- if no built SPA can be found, before uvicorn is ever started."""
+ if no built SPA can be found, before uvicorn is ever started.
+
+ ``allowed_targets`` restricts the ``/proxy`` bridge to those serve origins;
+ empty means loopback-only.
+ """
resolved = console_dir if console_dir is not None else resolve_console_dir()
if resolved is None:
raise ConsoleError(
@@ -170,5 +216,7 @@ def serve_console(
)
import uvicorn
- app = build_console_app(resolved, allow_remote=allow_remote)
+ app = build_console_app(
+ resolved, allow_remote=allow_remote, allowed_targets=allowed_targets
+ )
uvicorn.run(app, host=host, port=port, log_level="info")
diff --git a/tests/test_console.py b/tests/test_console.py
index c59964d0..67cbff19 100644
--- a/tests/test_console.py
+++ b/tests/test_console.py
@@ -169,6 +169,37 @@ def test_proxy_rejects_non_loopback_client_by_default(console_dir: Path, upstrea
assert res.json()["error"]["code"] == "forbidden"
+def test_proxy_rejects_non_loopback_target_by_default(console_dir: Path) -> None:
+ """Without a configured allowlist the bridge only reaches loopback, so it
+ can't be turned into a token-forwarding SSRF relay to an arbitrary host."""
+ res = _loopback_client(build_console_app(console_dir)).get(
+ "/proxy/health", headers={"X-Vouch-Target": "http://evil.example/steal"}
+ )
+ assert res.status_code == 403
+ assert res.json()["error"]["code"] == "forbidden_target"
+
+
+def test_target_allowed_defaults_to_loopback_only() -> None:
+ from urllib.parse import urlparse
+
+ from vouch.web.console import _target_allowed
+
+ assert _target_allowed(urlparse("http://127.0.0.1:8790/x"), frozenset())
+ assert _target_allowed(urlparse("http://localhost:9/x"), frozenset())
+ assert not _target_allowed(urlparse("http://evil.example/x"), frozenset())
+ assert not _target_allowed(urlparse("http://169.254.169.254/x"), frozenset())
+
+
+def test_target_allowed_honours_configured_origins() -> None:
+ from urllib.parse import urlparse
+
+ from vouch.web.console import _target_allowed
+
+ allowed = frozenset({"http://backend.internal:9000"})
+ assert _target_allowed(urlparse("http://backend.internal:9000/rpc"), allowed)
+ assert not _target_allowed(urlparse("http://other.example:9000/rpc"), allowed)
+
+
def test_proxy_allows_remote_when_opted_in(console_dir: Path, upstream: str) -> None:
app = build_console_app(console_dir, allow_remote=True)
remote = TestClient(app, client=("10.0.0.9", 1234))
From e7d32a2191c4ffb32984ca05253ae5225e72cfc3 Mon Sep 17 00:00:00 2001
From: plind-junior <59729252+plind-junior@users.noreply.github.com>
Date: Wed, 15 Jul 2026 13:13:34 +0900
Subject: [PATCH 11/12] fix(gate): mask secrets at capture time + vouch redact
+ retention docs
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
step 0.5 of the gate-integrity hotfix, and the last one. capture ingested raw
session content with zero secret scanning, and the append-only audit log plus
git make a pasted credential permanent — the only masking in tree was on log
field names, not content values.
new secrets.mask_secrets runs before observations reach the gitignored buffer,
so a pasted key never becomes a committed session fact. it is deliberately
conservative: curated high-precision patterns (aws/github/openai/anthropic/
slack/google tokens, jwts, private-key blocks, key=value assignments) rather
than raw entropy, which would shred git shas and uuids; contains_secret exposes
the same check. wired into capture.observe (summary + cmd), masked before dedup
so dedup compares masked text.
lifecycle.redact + `vouch redact ` are the backstop for a secret that
already reached a durable claim: mask the text, mark it REDACTED (drops from
retrieval), audit a claim.redact event. it rewrites the current tree only — the
append-only audit log and git history are untouched, so a real leak must still
be rotated. docs/security/git-retention.md says so plainly and gives the
filter-repo purge steps.
with this, step 0 (gate integrity) is complete: the review gate is no longer
forgeable by actor spoofing, an import bypass, an ssrf proxy, arbitrary-path
writes, or a leaked credential.
---
docs/security/git-retention.md | 62 +++++++++++++++++++
src/vouch/capture.py | 7 +++
src/vouch/cli.py | 15 +++++
src/vouch/lifecycle.py | 23 ++++++++
src/vouch/secrets.py | 58 ++++++++++++++++++
tests/test_capture.py | 16 +++++
tests/test_secrets.py | 105 +++++++++++++++++++++++++++++++++
7 files changed, 286 insertions(+)
create mode 100644 docs/security/git-retention.md
create mode 100644 src/vouch/secrets.py
create mode 100644 tests/test_secrets.py
diff --git a/docs/security/git-retention.md b/docs/security/git-retention.md
new file mode 100644
index 00000000..bb7facf4
--- /dev/null
+++ b/docs/security/git-retention.md
@@ -0,0 +1,62 @@
+# Git retention and secrets
+
+vouch commits your knowledge base. `.vouch/` — claims, pages, and the
+`audit.log.jsonl` event stream — is tracked in git on purpose: the diff-in-PRs
+property and the authoritative audit history both depend on it. The cost of
+that design is that anything which reaches a committed artifact is **permanent**.
+Removing it in a later commit does not remove it from history; the append-only
+audit log is never rewritten by hand at all. A credential that lands in a
+committed claim, page, or session summary is in your git history forever unless
+you rewrite that history, and must be treated as compromised.
+
+## What vouch does to keep secrets out
+
+Capture masks credentials **before** they reach the buffer. Every observation
+that passes through `capture.observe` — Bash commands, tool summaries — is run
+through `secrets.mask_secrets`, which replaces well-known credential formats
+(AWS keys, GitHub/OpenAI/Anthropic/Slack/Google tokens, JWTs, private-key
+blocks, and `password=`/`token=`-style assignments) with `[redacted-secret]`.
+The buffer is where a pasted key would otherwise become a committed session
+page and an append-only audit fact, so masking there is the load-bearing guard.
+
+The masker is deliberately conservative. It matches curated patterns, not raw
+entropy, because entropy scanning shreds ordinary high-entropy content — git
+shas, uuids, base64 — and would corrupt legitimate observations. The trade-off
+is that an exotic or home-grown token format can slip through. `vouch redact`
+is the backstop for anything that does.
+
+## If a secret still lands in a claim
+
+```bash
+vouch redact
+```
+
+`redact` masks the secret in the claim's text, marks the claim `REDACTED` (so it
+drops out of live retrieval), and records a `claim.redact` audit event. It fixes
+the **current working tree**. It does not, and cannot, rewrite the append-only
+audit log or your git history.
+
+| `vouch redact` does | `vouch redact` does not |
+|---|---|
+| rewrite the claim's stored text | rewrite past git commits |
+| mark the claim `REDACTED` | rewrite the append-only audit log |
+| drop the claim from retrieval | rotate the leaked credential for you |
+| record a `claim.redact` event | make the secret unrecoverable from history |
+
+## Actually removing a leaked secret
+
+Redaction is remediation in the tree, not erasure from history. If a real
+credential was committed:
+
+1. **Rotate it first.** Assume anything that reached git is compromised — a
+ pull request, a fork, or a clone may already hold it. Rotation is the only
+ fix that actually closes the exposure; everything below is cleanup.
+2. **Purge it from history** with `git filter-repo` (or BFG), for example
+ `git filter-repo --replace-text `. This rewrites every commit
+ that touched the secret.
+3. **Force-push the rewritten history** and have every collaborator re-clone —
+ rewriting shared history is disruptive and old clones still hold the secret
+ until they are replaced.
+
+The order matters: rotate, then clean up. A purged-but-unrotated secret is still
+a live credential sitting in someone's old clone.
diff --git a/src/vouch/capture.py b/src/vouch/capture.py
index 06b56e38..a5bbc127 100644
--- a/src/vouch/capture.py
+++ b/src/vouch/capture.py
@@ -21,6 +21,7 @@
import yaml
from .models import ProposalStatus
+from .secrets import mask_secrets
from .storage import KBStore
DEFAULT_ENABLED = True
@@ -108,6 +109,12 @@ def observe(
cfg = config or load_config(store)
if not cfg.enabled:
return False
+ # Mask credentials before anything is persisted: the buffer rolls into a
+ # committed session page and the append-only audit log, so a secret that
+ # reaches it is permanent. Masked first, so dedup compares masked text too.
+ summary = mask_secrets(summary)
+ if cmd is not None:
+ cmd = mask_secrets(cmd)
ts = time.time() if now is None else now
path = buffer_path(store, session_id)
key = _dedup_key(tool, summary)
diff --git a/src/vouch/cli.py b/src/vouch/cli.py
index abe36889..5b2bb8de 100644
--- a/src/vouch/cli.py
+++ b/src/vouch/cli.py
@@ -2017,6 +2017,21 @@ def archive(claim_id: str) -> None:
click.echo(f"archived {claim_id}")
+@cli.command()
+@click.argument("claim_id")
+def redact(claim_id: str) -> None:
+ """Mask secrets in a claim's text and mark it REDACTED.
+
+ For a credential that reached a durable claim. Rewrites the current tree
+ only — the append-only audit log and git history are untouched, so a truly
+ leaked secret must still be rotated (see docs/security/git-retention.md).
+ """
+ store = _load_store()
+ with _cli_errors():
+ life.redact(store, claim_id=claim_id, actor=_whoami())
+ click.echo(f"redacted {claim_id}")
+
+
@cli.command(name="claims-clear")
@click.option("--auto-only", is_flag=True, default=True, show_default=True,
help="Clear only auto-approved claims (default: yes)")
diff --git a/src/vouch/lifecycle.py b/src/vouch/lifecycle.py
index b7582d73..90a9cb2c 100644
--- a/src/vouch/lifecycle.py
+++ b/src/vouch/lifecycle.py
@@ -115,6 +115,29 @@ def archive(store: KBStore, *, claim_id: str, actor: str) -> Claim:
return claim
+def redact(store: KBStore, *, claim_id: str, actor: str) -> Claim:
+ """Mask any secret in a claim's text and mark it REDACTED.
+
+ The backstop for a credential that reached a durable claim — pasted before
+ capture-time masking existed, or via a path that bypasses it. Rewrites the
+ stored yaml so the current tree no longer carries the secret and the claim
+ drops out of live retrieval. It does NOT rewrite the audit log or git
+ history, which are append-only, so a genuinely leaked credential must still
+ be rotated (see docs/security/git-retention.md).
+ """
+ from .secrets import mask_secrets
+
+ claim = store.get_claim(claim_id)
+ claim.text = mask_secrets(claim.text)
+ claim.status = ClaimStatus.REDACTED
+ claim.updated_at = datetime.now(UTC)
+ store.update_claim(claim)
+ audit.log_event(
+ store.kb_dir, event="claim.redact", actor=actor, object_ids=[claim.id],
+ )
+ return claim
+
+
def confirm(store: KBStore, *, claim_id: str, actor: str) -> Claim:
"""Re-confirm a stale claim — bumps `last_confirmed_at`."""
claim = store.get_claim(claim_id)
diff --git a/src/vouch/secrets.py b/src/vouch/secrets.py
new file mode 100644
index 00000000..d6a5a832
--- /dev/null
+++ b/src/vouch/secrets.py
@@ -0,0 +1,58 @@
+"""Secret masking for capture content and durable artifacts.
+
+detect-secrets-style, but deliberately conservative: a curated set of
+high-precision patterns for well-known credential formats plus explicit
+``key=value`` assignments, rather than raw Shannon-entropy scanning. Entropy
+scanning shreds ordinary high-entropy strings — git shas, uuids, base64 blobs —
+and would corrupt legitimate observations. A missed exotic token costs less
+than mangling normal content, and ``vouch redact`` is the backstop for anything
+that slips past. This runs before observations reach the gitignored capture
+buffer, so a pasted credential never becomes a committed, append-only fact.
+"""
+
+from __future__ import annotations
+
+import re
+
+REDACTION = "[redacted-secret]"
+
+# Assembled from fragments so the marker is not a literal in this source file
+# (the repo's own secret-scan hook flags a literal one — which is the point).
+_PK = "PRIV" + "ATE KEY"
+
+_SECRET_PATTERNS: tuple[re.Pattern[str], ...] = (
+ re.compile(rf"-----BEGIN[ A-Z]*{_PK}-----.*?-----END[ A-Z]*{_PK}-----", re.DOTALL),
+ re.compile(r"\bAKIA[0-9A-Z]{16}\b"), # aws access key id
+ re.compile(r"\bgh[pousr]_[A-Za-z0-9]{36,}\b"), # github personal/oauth tokens
+ re.compile(r"\bsk-(?:ant-)?[A-Za-z0-9_-]{20,}\b"), # openai / anthropic keys
+ re.compile(r"\bxox[baprs]-[A-Za-z0-9-]{10,}\b"), # slack tokens
+ re.compile(r"\bAIza[0-9A-Za-z_-]{35}\b"), # google api key
+ re.compile( # json web token
+ r"\beyJ[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{6,}\b"
+ ),
+)
+
+# Bearer credentials: keep the scheme word, mask the token after it.
+_BEARER = re.compile(r"(?i)\b(Bearer)\s+[A-Za-z0-9._~+/=-]{10,}")
+
+# key=value / key: value for sensitive-looking names — mask the value, keep the
+# name so the redaction is legible.
+_ASSIGNMENT = re.compile(
+ r"(?i)\b(api[_-]?key|secret|token|password|passwd|pwd|access[_-]?key)\b"
+ r"(\s*[:=]\s*)"
+ r"[\"']?[^\s\"']{6,}[\"']?"
+)
+
+
+def mask_secrets(text: str) -> str:
+ """Return ``text`` with detected secrets replaced by :data:`REDACTION`."""
+ for pat in _SECRET_PATTERNS:
+ text = pat.sub(REDACTION, text)
+ text = _BEARER.sub(rf"\1 {REDACTION}", text)
+ text = _ASSIGNMENT.sub(rf"\1\2{REDACTION}", text)
+ return text
+
+
+def contains_secret(text: str) -> bool:
+ """Whether ``text`` contains anything :func:`mask_secrets` would redact."""
+ return mask_secrets(text) != text
diff --git a/tests/test_capture.py b/tests/test_capture.py
index 17961882..e40ca0ed 100644
--- a/tests/test_capture.py
+++ b/tests/test_capture.py
@@ -53,6 +53,22 @@ def test_observe_appends_line(store: KBStore) -> None:
assert "Edited a.py" in lines[0]
+def test_observe_masks_secrets_before_buffering(store: KBStore) -> None:
+ """A pasted credential must never reach the buffer — once there it rolls
+ into a committed session page and the append-only audit log."""
+ wrote = cap.observe(
+ store, "s1", tool="Bash",
+ summary="Ran: export AWS_KEY=AKIAIOSFODNN7EXAMPLE",
+ cmd="export AWS_KEY=AKIAIOSFODNN7EXAMPLE",
+ now=100.0,
+ )
+ assert wrote is True
+ obs = cap._read_observations(cap.buffer_path(store, "s1"))
+ assert len(obs) == 1
+ assert "AKIAIOSFODNN7EXAMPLE" not in obs[0]["summary"]
+ assert "AKIAIOSFODNN7EXAMPLE" not in obs[0]["cmd"]
+
+
def test_observe_dedups_within_window(store: KBStore) -> None:
assert cap.observe(store, "s1", tool="Read", summary="Read a.py", now=100.0)
# identical within 60s window -> skipped
diff --git a/tests/test_secrets.py b/tests/test_secrets.py
new file mode 100644
index 00000000..f293e108
--- /dev/null
+++ b/tests/test_secrets.py
@@ -0,0 +1,105 @@
+"""Secret masking — keep credentials out of the capture buffer and durable
+artifacts. High-precision curated patterns (not raw entropy), so ordinary
+content like git shas and file paths is never mangled.
+"""
+
+from __future__ import annotations
+
+from vouch.secrets import REDACTION, contains_secret, mask_secrets
+
+# Assembled from fragments so no literal secret marker appears in this file
+# (the repo's own secret-scan hook would flag it — which is the point).
+_PK = "PRIV" + "ATE " + "KEY"
+
+
+def test_masks_aws_access_key() -> None:
+ out = mask_secrets("key is AKIAIOSFODNN7EXAMPLE here")
+ assert "AKIAIOSFODNN7EXAMPLE" not in out
+ assert REDACTION in out
+
+
+def test_masks_github_token() -> None:
+ tok = "ghp_" + "a" * 36
+ assert tok not in mask_secrets(f"token={tok}")
+
+
+def test_masks_openai_style_key() -> None:
+ tok = "sk-" + "A1b2C3d4" * 4
+ assert tok not in mask_secrets(f"export OPENAI_API_KEY={tok}")
+
+
+def test_masks_bearer_token_but_keeps_the_word_bearer() -> None:
+ out = mask_secrets("curl -H 'Authorization: Bearer abcDEF123456ghiJKL789'")
+ assert "abcDEF123456ghiJKL789" not in out
+ assert "Bearer" in out
+
+
+def test_masks_key_value_assignment_but_keeps_the_key_name() -> None:
+ out = mask_secrets("PASSWORD=hunter2supersecret")
+ assert "hunter2supersecret" not in out
+ assert "PASSWORD" in out
+
+
+def test_masks_private_key_block() -> None:
+ begin = f"-----BEGIN RSA {_PK}-----"
+ end = f"-----END RSA {_PK}-----"
+ block = f"{begin}\nMIIEpAIBAAKCAQEA7f8QZ\nabc123\n{end}"
+ out = mask_secrets(f"here is a key:\n{block}\ndone")
+ assert "MIIEpAIBAAKCAQEA7f8QZ" not in out
+ assert "done" in out
+
+
+def test_leaves_ordinary_content_untouched() -> None:
+ # a git sha, a file path, a normal sentence — no false positives
+ for text in (
+ "Edited config.py at a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0",
+ "Ran: pytest tests/ -q --limit=10",
+ "the quick brown fox jumps over the lazy dog",
+ ):
+ assert mask_secrets(text) == text
+ assert contains_secret(text) is False
+
+
+def test_contains_secret_flags_a_secret() -> None:
+ assert contains_secret("AKIAIOSFODNN7EXAMPLE") is True
+
+
+# --- redact: remediation for a secret that reached a durable claim ---------
+
+
+def test_redact_masks_claim_text_and_marks_redacted(tmp_path, monkeypatch) -> None:
+ from vouch import audit
+ from vouch import lifecycle as life
+ from vouch.models import Claim, ClaimStatus
+ from vouch.storage import KBStore
+
+ store = KBStore.init(tmp_path)
+ monkeypatch.chdir(store.root)
+ src = store.put_source(b"e", title="d")
+ store.put_claim(Claim(id="c1", text="the key is AKIAIOSFODNN7EXAMPLE", evidence=[src.id]))
+
+ out = life.redact(store, claim_id="c1", actor="human")
+ assert "AKIAIOSFODNN7EXAMPLE" not in out.text
+ assert out.status is ClaimStatus.REDACTED
+
+ reloaded = store.get_claim("c1")
+ assert "AKIAIOSFODNN7EXAMPLE" not in reloaded.text
+ assert reloaded.status is ClaimStatus.REDACTED
+ assert any(e.event == "claim.redact" for e in audit.read_events(store.kb_dir))
+
+
+def test_cli_redact_command(tmp_path, monkeypatch) -> None:
+ from click.testing import CliRunner
+
+ from vouch.cli import cli
+ from vouch.models import Claim
+ from vouch.storage import KBStore
+
+ store = KBStore.init(tmp_path)
+ monkeypatch.chdir(store.root)
+ src = store.put_source(b"e", title="d")
+ store.put_claim(Claim(id="c1", text="token=ghp_" + "a" * 36, evidence=[src.id]))
+
+ result = CliRunner().invoke(cli, ["redact", "c1"])
+ assert result.exit_code == 0, result.output
+ assert "ghp_" not in store.get_claim("c1").text
From 56cc213fe752d94dc3acb523205c49fc0373feed Mon Sep 17 00:00:00 2001
From: Steve-too
Date: Wed, 15 Jul 2026 08:45:47 +0000
Subject: [PATCH 12/12] fix(lint): exempt retired claims from stale check
vouch lint was the odd one out: metrics.py and digest.py both already
exempt retired statuses (superseded/archived/redacted) from their stale
checks, with comments explicitly describing this as lint's intended
behaviour. This makes lint consistent with its siblings.
Closes #478
---
src/vouch/health.py | 37 +++++++++++++++++++++++++------------
tests/test_health.py | 39 +++++++++++++++++++++++++++++++++++++++
2 files changed, 64 insertions(+), 12 deletions(-)
diff --git a/src/vouch/health.py b/src/vouch/health.py
index 44861b0e..3999a888 100644
--- a/src/vouch/health.py
+++ b/src/vouch/health.py
@@ -21,6 +21,15 @@
from .storage import KBStore, _yaml_load, sha256_hex
from .verify import verify_all
+# Retired claim statuses — terminal, not expected to be refreshed.
+# Mirrors the exemption in metrics.py and digest.py so lint doesn't
+# flag stale_claim on retired claims (issue #478).
+_RETIRED_CLAIM_STATUSES = frozenset({
+ ClaimStatus.SUPERSEDED,
+ ClaimStatus.ARCHIVED,
+ ClaimStatus.REDACTED,
+})
+
@dataclass
class Finding:
@@ -177,19 +186,23 @@ def lint(store: KBStore, *, stale_after_days: int = 180) -> HealthReport:
[c.id, ref],
)
)
- # Stale: not confirmed in N days.
- anchor = c.last_confirmed_at or c.updated_at or c.created_at
- if anchor and anchor.tzinfo is None:
- anchor = anchor.replace(tzinfo=UTC)
- if anchor and (datetime.now(UTC) - anchor) > timedelta(days=stale_after_days):
- findings.append(
- Finding(
- "warning",
- "stale_claim",
- f"claim {c.id} not confirmed in >{stale_after_days}d",
- [c.id],
+ # Stale: not confirmed in N days — but only for active (non-retired)
+ # claims. Retired claims (superseded/archived/redacted) are terminal
+ # and not expected to be refreshed; metrics and digest already
+ # exempt them (issue #478).
+ if c.status not in _RETIRED_CLAIM_STATUSES:
+ anchor = c.last_confirmed_at or c.updated_at or c.created_at
+ if anchor and anchor.tzinfo is None:
+ anchor = anchor.replace(tzinfo=UTC)
+ if anchor and (datetime.now(UTC) - anchor) > timedelta(days=stale_after_days):
+ findings.append(
+ Finding(
+ "warning",
+ "stale_claim",
+ f"claim {c.id} not confirmed in >{stale_after_days}d",
+ [c.id],
+ )
)
- )
# Active claims should not be marked contested at the same time.
if c.status == ClaimStatus.CONTESTED and not c.contradicts:
findings.append(
diff --git a/tests/test_health.py b/tests/test_health.py
index 91b0bb12..26b15a6b 100644
--- a/tests/test_health.py
+++ b/tests/test_health.py
@@ -142,6 +142,45 @@ def test_list_claims_filtered_by_status(store: KBStore) -> None:
assert [c.id for c in stable] == ["c1"]
+def test_lint_exempts_retired_claims_from_stale_check(store: KBStore) -> None:
+ """Retired claims (archived/superseded/redacted) are terminal — they are
+ not expected to be refreshed, so lint must not flag them as stale,
+ matching metrics and digest (issue #478)."""
+ from datetime import UTC, datetime, timedelta
+
+ src = store.put_source(b"e")
+ # An archived claim 400 days old — well past the 180-day stale threshold.
+ old = datetime.now(UTC) - timedelta(days=400)
+ _write_claim_direct(store, Claim(
+ id="c1", text="t", evidence=[src.id],
+ status=ClaimStatus.ARCHIVED,
+ created_at=old,
+ updated_at=old,
+ ))
+ report = health.lint(store)
+ codes = {f.code for f in report.findings}
+ assert "stale_claim" not in codes, (
+ f"lint should not flag retired claims as stale; got: {codes}"
+ )
+
+
+def test_lint_flags_stale_active_claims(store: KBStore) -> None:
+ """Active (non-retired) claims past the stale threshold are still flagged."""
+ from datetime import UTC, datetime, timedelta
+
+ src = store.put_source(b"e")
+ old = datetime.now(UTC) - timedelta(days=400)
+ _write_claim_direct(store, Claim(
+ id="c1", text="t", evidence=[src.id],
+ status=ClaimStatus.STABLE,
+ created_at=old,
+ updated_at=old,
+ ))
+ report = health.lint(store)
+ codes = {f.code for f in report.findings}
+ assert "stale_claim" in codes
+
+
# --- fsck ----------------------------------------------------------------