Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 21 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,27 @@ All notable changes to vouch are documented here. Format follows

## [Unreleased]

### Changed
- the starter config now ships `review.auto_approve_on_receipt: true`
(and `require_human_approval: false`, an advisory key no code path
reads): a fresh KB auto-approves captured claims whose byte-offset
receipts verify against their source, so recall works out of the box
with no `vouch review` pass. the gate is unchanged for everything
the receipt check cannot vouch for — pages (session summaries
included), entities, relations, and claims that cannot quote their
source still wait for a human. existing KBs keep whatever their
`.vouch/config.yaml` says; set `auto_approve_on_receipt: false` to
restore the fully human gate.
- the receipt drain now runs at every session start (`capture
finalize-all`), so verifiable claims left pending while the gate was
off are approved instead of stranded, and it is duplicate-safe: a
claim re-deriving text that is already durable is mechanically
rejected ("duplicate: identical claim already durable") rather than
crashing the drain or piling up in the review queue. a claim id held
by *different* text is a real conflict and stays pending for a human.
the same resolution now backs `capture answer`, which previously left
re-captured duplicates pending forever.

## [1.4.0] — 2026-07-17

### Added
Expand Down
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -144,6 +144,8 @@ compile:
vouch review # walk pending proposals one at a time
```

Receipt-verified claims skip the queue by default (`review.auto_approve_on_receipt: true` in the starter config): each session's captured answers become recallable memory with no review pass. What lands in `vouch review` is everything the mechanical check can't vouch for — session-summary pages, entities, relations, and claims that can't quote their source. Set the flag to `false` in `.vouch/config.yaml` to put every write behind the gate.

**Want a browser UI for reviewing and proposing?** The video shows the **vouch webapp** — chat, review queue, claims, and stats. Your options:

- **No setup**: the Docker demo above
Expand Down
2 changes: 1 addition & 1 deletion adapters/claude-code/.claude/settings.json
Original file line number Diff line number Diff line change
Expand Up @@ -68,7 +68,7 @@
],
"Stop": [
{
"comment": "save this turn's answer as durable, recallable knowledge — receipt-backed claims, auto-approved only under the review opt-in (trusted-agent / auto_approve_on_receipt); fires every turn but skips short/duplicate answers; never blocks the turn",
"comment": "save this turn's answer as durable, recallable knowledge — receipt-verified claims auto-approve under the starter-config default (review.auto_approve_on_receipt; set false to keep every write behind vouch review); fires every turn but skips short/duplicate answers; never blocks the turn",
"matcher": "*",
"hooks": [
{
Expand Down
15 changes: 10 additions & 5 deletions docs/getting-started.md
Original file line number Diff line number Diff line change
Expand Up @@ -95,11 +95,16 @@ vouch approve prop-abc123 --reason "matches the meeting notes"
The claim is now durable. The proposal moves to
`.vouch/decided/prop-abc123.yaml` (committed, for audit).

**Note on approval:** By default, vouch requires human approval and
prevents self-approval (a proposer cannot approve their own proposal).
For local testing, you can add `approver_role: trusted-agent` to
`.vouch/config.yaml`. Production deployments should keep the default
`require_human_approval: true` to preserve the review gate.
**Note on approval:** vouch prevents blanket self-approval (a proposer
cannot approve their own proposal), with one mechanical exception that
the starter config enables by default: a claim whose byte-offset
receipts all verify against their source is auto-approved — the receipt
is the reviewer (`review.auto_approve_on_receipt: true`). Everything
else — pages, entities, relations, and any claim that cannot quote its
source — still waits for a human. Set `auto_approve_on_receipt: false`
in `.vouch/config.yaml` to put every write behind `vouch review`, or
`approver_role: trusted-agent` to let an agent approve anything (local
testing only).

```bash
git add .vouch && git commit -m "kb: approve auth-uses-jwt"
Expand Down
41 changes: 30 additions & 11 deletions src/vouch/capture.py
Original file line number Diff line number Diff line change
Expand Up @@ -481,8 +481,9 @@ def capture_answer(
receipt-backed claim per quotable span (``extract.extract_receipt_claims``),
and approves each one the review gate allows — self-approval clears under
``review.approver_role: trusted-agent`` or, for these verbatim-quoting
claims, ``review.auto_approve_on_receipt``. With neither gate on the claims
stay pending: the review gate is honoured, never bypassed.
claims, ``review.auto_approve_on_receipt`` (the starter-config default).
With neither gate on the claims stay pending: the review gate is
honoured, never bypassed.

Idempotent and quiet by design: an answer already ingested (same bytes) is
skipped, and answers shorter than ``min_answer_chars`` (acknowledgements)
Expand All @@ -493,7 +494,6 @@ def capture_answer(

from . import extract as extract_mod
from . import proposals as proposals_mod
from .proposals import ProposalError
from .storage import ArtifactNotFoundError, sha256_hex

# vouch's own LLM subprocesses set this so the agent session they spawn does
Expand Down Expand Up @@ -531,15 +531,14 @@ def capture_answer(
)
approved = 0
for result in filed:
try:
proposals_mod.approve(
store, result.proposal.id, approved_by=ANSWER_ACTOR,
reason="auto-captured session answer (receipt verified)",
)
# approves under the gate, rejects duplicates of durable claims,
# leaves everything else pending — see resolve_pending_receipt_claim.
claim = proposals_mod.resolve_pending_receipt_claim(
store, result.proposal, actor=ANSWER_ACTOR,
reason="auto-captured session answer (receipt verified)",
)
if claim is not None:
approved += 1
except ProposalError:
# gate closed (no trusted-agent, no receipt opt-in): leave pending.
pass
return {
"captured": True, "skipped": None, "session_id": session_id,
"source": source.id, "filed": len(filed), "approved": approved,
Expand Down Expand Up @@ -568,6 +567,23 @@ def is_stale_buffer(
return age > max_age_seconds


def _drain_receipt_backlog(store: KBStore) -> int:
"""Auto-approve pending receipt-verified claims; count them.

With ``review.auto_approve_on_receipt`` on (the starter-config default)
this clears any backlog of verifiable claims left pending while the gate
was off — e.g. a kb that flipped the flag after capturing. No-op when the
gate is off, and never fatal: it runs from a SessionStart hook that must
not break the session.
"""
from . import proposals as proposals_mod

try:
return len(proposals_mod.auto_approve_receipts(store))
except Exception:
return 0


def finalize_all_except(
store: KBStore,
current_session_id: str,
Expand All @@ -582,6 +598,7 @@ def finalize_all_except(
- finalized: [session_id1, session_id2, ...] session IDs that were finalized
- skipped_recent: [id3, id4, ...] sessions too recent to finalize
- skipped_current: [id5] the current session (always skipped)
- auto_approved: count of pending receipt-verified claims drained on the way
"""
finalized: list[str] = []
skipped_recent: list[str] = []
Expand All @@ -594,6 +611,7 @@ def finalize_all_except(
"finalized": finalized,
"skipped_recent": skipped_recent,
"skipped_current": skipped_current,
"auto_approved": _drain_receipt_backlog(store),
}

for path in sorted(caps_dir.glob("*.jsonl")):
Expand Down Expand Up @@ -621,4 +639,5 @@ def finalize_all_except(
"finalized": finalized,
"skipped_recent": skipped_recent,
"skipped_current": skipped_current,
"auto_approved": _drain_receipt_backlog(store),
}
5 changes: 3 additions & 2 deletions src/vouch/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -2419,8 +2419,9 @@ def capture_answer_cmd(session_id: str | None) -> None:

The Stop hook emits {session_id, transcript_path} on stdin; the session's
answer is ingested as a source and its receipt-backed claims are
auto-approved under the review opt-in (trusted-agent / auto_approve_on_receipt),
else left pending. Always exits 0 so a capture failure can never break the turn.
auto-approved when review.auto_approve_on_receipt is on (the
starter-config default) or under trusted-agent, else left pending.
Always exits 0 so a capture failure can never break the turn.
"""
if sys.stdin.isatty():
return
Expand Down
69 changes: 55 additions & 14 deletions src/vouch/proposals.py
Original file line number Diff line number Diff line change
Expand Up @@ -478,33 +478,74 @@ def _approval_block_reason(
return None


def resolve_pending_receipt_claim(
store: KBStore, proposal: Proposal, *, actor: str, reason: str
) -> Claim | None:
"""Mechanically decide one pending CLAIM proposal, honouring the gate.

Returns the durable Claim when self-approval clears — under
``review.approver_role: trusted-agent``, or when the claim's byte-offset
receipts all verify under ``review.auto_approve_on_receipt``. Returns None
when the proposal stays pending (gate closed, receipts unverifiable, or
its id is held by a claim with *different* text — a real conflict, a human
call) or when it was rejected as a duplicate: re-deriving a claim whose
identical text is already durable adds nothing, so the proposal is closed
with a duplicate reason instead of piling up in the review queue.
"""
if proposal.kind != ProposalKind.CLAIM:
return None
review_cfg = _review_config(store)
trusted = review_cfg.get("approver_role") == "trusted-agent"
receipted = bool(
review_cfg.get("auto_approve_on_receipt")
) and _claim_receipts_verify(store, proposal)
if not (trusted or receipted):
return None
Comment on lines +495 to +503

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Enforce receipt verification before checking the review gate.

If approver_role: trusted-agent is enabled, trusted will be True, allowing resolve_pending_receipt_claim to bypass the _claim_receipts_verify check. Because auto_approve_receipts iterates over all pending proposals, this bug causes the capture finalize-all session hook to silently approve all pending claims (including those without receipts or with forged receipts), violating the intended behavior of only draining receipt-verified claims.

Enforce the receipt verification check upfront so the function strictly operates on verified claims.

🐛 Proposed fix
     if proposal.kind != ProposalKind.CLAIM:
         return None
+    if not _claim_receipts_verify(store, proposal):
+        return None
     review_cfg = _review_config(store)
     trusted = review_cfg.get("approver_role") == "trusted-agent"
-    receipted = bool(
-        review_cfg.get("auto_approve_on_receipt")
-    ) and _claim_receipts_verify(store, proposal)
+    receipted = bool(review_cfg.get("auto_approve_on_receipt"))
     if not (trusted or receipted):
         return None
📝 Committable suggestion

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

Suggested change
if proposal.kind != ProposalKind.CLAIM:
return None
review_cfg = _review_config(store)
trusted = review_cfg.get("approver_role") == "trusted-agent"
receipted = bool(
review_cfg.get("auto_approve_on_receipt")
) and _claim_receipts_verify(store, proposal)
if not (trusted or receipted):
return None
if proposal.kind != ProposalKind.CLAIM:
return None
if not _claim_receipts_verify(store, proposal):
return None
review_cfg = _review_config(store)
trusted = review_cfg.get("approver_role") == "trusted-agent"
receipted = bool(review_cfg.get("auto_approve_on_receipt"))
if not (trusted or receipted):
return None
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/vouch/proposals.py` around lines 495 - 503, Update
resolve_pending_receipt_claim to call _claim_receipts_verify(store, proposal)
before evaluating the approver_role review gate, and return None immediately
when verification fails. Keep the existing trusted-agent and auto-approval
checks only after this upfront verification so the function processes
exclusively verified claims.

claim_id = str(proposal.payload.get("id", ""))
existing: Claim | None = None
if claim_id:
try:
existing = store.get_claim(claim_id)
except ArtifactNotFoundError:
existing = None
if existing is not None:
if existing.text == proposal.payload.get("text"):
reject(
store, proposal.id, rejected_by=actor,
reason="duplicate: identical claim already durable",
)
return None
result = approve(store, proposal.id, approved_by=actor, reason=reason)
assert isinstance(result, Claim) # kind == CLAIM guaranteed above
return result


def auto_approve_receipts(
store: KBStore, *, actor: str | None = None
) -> list[Claim]:
"""Approve every pending receipt-verified claim, no human in the loop.

The mechanical gate is the reviewer: a pending CLAIM whose citations all
carry receipts that verify by string comparison is approved; anything else
— a bare source id, a forged or missing receipt, a non-claim proposal — is
left pending for a human. This is the drain that makes "run vouch and it
just captures knowledge" real. No-op unless ``review.auto_approve_on_receipt``
is set, so the human-review gate is never silently bypassed.
carry receipts that verify by string comparison is approved; a duplicate
of an already-durable identical claim is rejected (see
``resolve_pending_receipt_claim``); anything else — a bare source id, a
forged or missing receipt, a non-claim proposal, an id held by different
text — is left pending for a human. This is the drain that makes "run
vouch and it just captures knowledge" real. No-op unless
``review.auto_approve_on_receipt`` is set, so the human-review gate is
never silently bypassed.
"""
if not _review_config(store).get("auto_approve_on_receipt"):
return []
approved: list[Claim] = []
for proposal in store.list_proposals(ProposalStatus.PENDING):
if proposal.kind != ProposalKind.CLAIM or not _claim_receipts_verify(
store, proposal
):
continue
result = approve(
store, proposal.id,
approved_by=actor or proposal.proposed_by,
claim = resolve_pending_receipt_claim(
store, proposal,
actor=actor or proposal.proposed_by,
reason="receipt verified — auto-approved",
)
assert isinstance(result, Claim) # kind == CLAIM guaranteed above
approved.append(result)
if claim is not None:
approved.append(claim)
return approved


Expand Down
9 changes: 5 additions & 4 deletions src/vouch/storage.py
Original file line number Diff line number Diff line change
Expand Up @@ -77,13 +77,14 @@ def _starter_config() -> dict[str, Any]:
return {
"version": KB_FORMAT_VERSION,
"review": {
"require_human_approval": True,
"require_human_approval": False,
"expire_pending_after_days": 90,
# phase d — the receipt is the reviewer. When true, a claim whose
# byte-offset receipts all verify is auto-approved with no human;
# a claim that cannot quote its source is left pending. Opt-in:
# the human-review gate stays on by default.
"auto_approve_on_receipt": False,
# a claim that cannot quote its source is left pending, as is
# every page/entity/relation proposal. Set false to put every
# write behind `vouch review`.
"auto_approve_on_receipt": True,
},
"capture": {
# auto-capture agent sessions into pending summaries.
Expand Down
17 changes: 17 additions & 0 deletions tests/test_capture.py
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,23 @@ def test_starter_config_has_capture_namespace() -> None:
assert _starter_config()["capture"]["enabled"] is True


def test_finalize_all_drains_receipt_backlog(store: KBStore) -> None:
# a verifiable claim left pending (e.g. filed while the gate was off) is
# drained on the next session start, not stranded in the queue forever.
from vouch.models import ProposalStatus
from vouch.proposals import propose_quoted_claim

src = store.put_source(b"the pipeline has four stages")
res = propose_quoted_claim(
store, text="the pipeline has four stages", source_id=src.id,
quote="the pipeline has four stages", proposed_by="agent-a",
)
assert res is not None
result = cap.finalize_all_except(store, "current-session")
assert result["auto_approved"] == 1
assert store.get_proposal(res.id).status is ProposalStatus.APPROVED


def test_init_gitignores_captures(tmp_path: Path) -> None:
kb = KBStore.init(tmp_path)
assert "captures/" in (kb.kb_dir / ".gitignore").read_text()
Expand Down
29 changes: 28 additions & 1 deletion tests/test_capture_answer.py
Original file line number Diff line number Diff line change
Expand Up @@ -135,7 +135,10 @@ def test_capture_answer_approves_under_trusted_agent(store: KBStore, tmp_path: P


def test_capture_answer_leaves_pending_when_gate_off(store: KBStore, tmp_path: Path) -> None:
# default starter config: neither opt-in set.
# both opt-ins explicitly off: every capture waits for a human.
store.config_path.write_text(
"review:\n auto_approve_on_receipt: false\n", encoding="utf-8"
)
tp = _transcript(tmp_path, [_user(QUESTION), _assistant(ANSWER)])
res = cap.capture_answer(store, "sess-1", tp)
assert res["captured"] is True
Expand All @@ -146,6 +149,30 @@ def test_capture_answer_leaves_pending_when_gate_off(store: KBStore, tmp_path: P
assert len(pending) >= 3


def test_capture_answer_recapture_leaves_no_pending_duplicates(
store: KBStore, tmp_path: Path
) -> None:
# a later answer restating already-durable facts must not pile up pending
# duplicates -- they are closed mechanically (rejected), fresh facts land.
tp1 = _transcript(tmp_path, [_user(QUESTION), _assistant(ANSWER)])
first = cap.capture_answer(store, "sess-1", tp1)
assert first["approved"] == first["filed"] >= 3
assert cap.pending_count(store) == 0

extra = (
"The observation buffer feeds passive capture across every host "
"adapter vouch ships."
)
tp2 = _transcript(tmp_path, [_user(QUESTION), _assistant(ANSWER + " " + extra)])
second = cap.capture_answer(store, "sess-2", tp2)
assert second["captured"] is True
# nothing waits for a human: fresh claims approved, restated ones rejected
# as duplicates of durable claims.
assert cap.pending_count(store) == 0
rejected = store.list_proposals(ProposalStatus.REJECTED)
assert any("duplicate" in (p.decision_reason or "") for p in rejected)


def test_capture_answer_skips_short_answer(store: KBStore, tmp_path: Path) -> None:
_enable_receipt_gate(store)
tp = _transcript(tmp_path, [_user(QUESTION), _assistant("done.")])
Expand Down
3 changes: 3 additions & 0 deletions tests/test_extract.py
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,9 @@ def test_ingest_source_auto_approves_and_is_recallable(store: KBStore) -> None:


def test_ingest_source_leaves_pending_when_gate_off(store: KBStore) -> None:
store.config_path.write_text(
"review:\n auto_approve_on_receipt: false\n", encoding="utf-8"
)
_src, approved = extract.ingest_source(store, SOURCE, proposed_by="agent")
assert approved == []
# proposed and receipt-backed, but still waiting for a human.
Expand Down
Loading
Loading