diff --git a/CHANGELOG.md b/CHANGELOG.md index 65407d84..9023f737 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/README.md b/README.md index dccfa314..e921b300 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/adapters/claude-code/.claude/settings.json b/adapters/claude-code/.claude/settings.json index 8e231f8e..39d3815e 100644 --- a/adapters/claude-code/.claude/settings.json +++ b/adapters/claude-code/.claude/settings.json @@ -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": [ { diff --git a/docs/getting-started.md b/docs/getting-started.md index b1003078..30cf81ea 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -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" diff --git a/src/vouch/capture.py b/src/vouch/capture.py index ddfdf025..6a9246b3 100644 --- a/src/vouch/capture.py +++ b/src/vouch/capture.py @@ -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) @@ -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 @@ -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, @@ -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, @@ -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] = [] @@ -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")): @@ -621,4 +639,5 @@ def finalize_all_except( "finalized": finalized, "skipped_recent": skipped_recent, "skipped_current": skipped_current, + "auto_approved": _drain_receipt_backlog(store), } diff --git a/src/vouch/cli.py b/src/vouch/cli.py index 2ca65bca..69099587 100644 --- a/src/vouch/cli.py +++ b/src/vouch/cli.py @@ -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 diff --git a/src/vouch/proposals.py b/src/vouch/proposals.py index ef90cbad..501439cd 100644 --- a/src/vouch/proposals.py +++ b/src/vouch/proposals.py @@ -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 + 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 diff --git a/src/vouch/storage.py b/src/vouch/storage.py index f3fb1552..b82c7171 100644 --- a/src/vouch/storage.py +++ b/src/vouch/storage.py @@ -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. diff --git a/tests/test_capture.py b/tests/test_capture.py index e40ca0ed..2a7ca4ee 100644 --- a/tests/test_capture.py +++ b/tests/test_capture.py @@ -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() diff --git a/tests/test_capture_answer.py b/tests/test_capture_answer.py index ed87e3ec..2b918881 100644 --- a/tests/test_capture_answer.py +++ b/tests/test_capture_answer.py @@ -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 @@ -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.")]) diff --git a/tests/test_extract.py b/tests/test_extract.py index 1af16bba..270a4d2f 100644 --- a/tests/test_extract.py +++ b/tests/test_extract.py @@ -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. diff --git a/tests/test_receipt_auto_approve.py b/tests/test_receipt_auto_approve.py index 69ff531a..7d972016 100644 --- a/tests/test_receipt_auto_approve.py +++ b/tests/test_receipt_auto_approve.py @@ -35,6 +35,12 @@ def _enable_receipt_gate(store: KBStore) -> None: ) +def _disable_receipt_gate(store: KBStore) -> None: + store.config_path.write_text( + "review:\n auto_approve_on_receipt: false\n", encoding="utf-8" + ) + + def test_receipt_verified_claim_self_approves_when_gate_on(store: KBStore) -> None: _enable_receipt_gate(store) src = store.put_source(b"the sky is blue today") @@ -61,8 +67,23 @@ def test_bare_source_claim_still_needs_human_when_gate_on(store: KBStore) -> Non approve(store, res.id, approved_by="agent-a") +def test_receipt_gate_on_by_default(store: KBStore) -> None: + # the starter config ships with auto_approve_on_receipt: true -- a fresh + # kb lets a verifying receipt clear self-approval with no config edit. + src = store.put_source(b"the sky is blue today") + res = propose_quoted_claim( + store, text="the sky is blue", source_id=src.id, + quote="the sky is blue", proposed_by="agent-a", + ) + assert res is not None + claim = approve(store, res.id, approved_by="agent-a") + assert store.get_proposal(res.id).status is ProposalStatus.APPROVED + assert store.get_claim(claim.id).text == "the sky is blue" + + def test_receipt_claim_blocked_when_gate_off(store: KBStore) -> None: - # default config: gate off -- a verifying receipt does not grant self-approval. + # gate explicitly off -- a verifying receipt does not grant self-approval. + _disable_receipt_gate(store) src = store.put_source(b"the sky is blue") res = propose_quoted_claim( store, text="the sky is blue", source_id=src.id, @@ -108,9 +129,54 @@ def test_auto_approve_receipts_drains_verified_leaves_unverified( def test_auto_approve_receipts_noop_when_gate_off(store: KBStore) -> None: + _disable_receipt_gate(store) src = store.put_source(b"alpha beta") propose_quoted_claim( store, text="mentions beta", source_id=src.id, quote="beta", proposed_by="agent-a", ) assert auto_approve_receipts(store) == [] + + +def test_auto_approve_receipts_rejects_duplicate_of_durable_claim( + store: KBStore, +) -> None: + # a session re-deriving a fact that is already durable must not crash the + # drain or pile up in the queue -- the duplicate is closed mechanically. + src = store.put_source(b"alpha beta gamma") + first = propose_quoted_claim( + store, text="mentions beta", source_id=src.id, quote="beta", + proposed_by="agent-a", + ) + assert first is not None + assert len(auto_approve_receipts(store)) == 1 + + again = propose_quoted_claim( + store, text="mentions beta", source_id=src.id, quote="beta", + proposed_by="agent-a", + ) + assert again is not None + assert auto_approve_receipts(store) == [] + decided = store.get_proposal(again.id) + assert decided.status is ProposalStatus.REJECTED + assert "duplicate" in (decided.decision_reason or "") + + +def test_auto_approve_receipts_leaves_id_conflict_pending(store: KBStore) -> None: + # same claim id, different text: not a duplicate but a conflict -- the + # drain never overwrites or rejects it, a human decides. + src = store.put_source(b"alpha beta gamma") + first = propose_quoted_claim( + store, text="mentions beta", source_id=src.id, quote="beta", + proposed_by="agent-a", slug_hint="shared-id", + ) + assert first is not None + assert len(auto_approve_receipts(store)) == 1 + + other = propose_quoted_claim( + store, text="mentions gamma", source_id=src.id, quote="gamma", + proposed_by="agent-a", slug_hint="shared-id", + ) + assert other is not None + assert auto_approve_receipts(store) == [] + assert store.get_proposal(other.id).status is ProposalStatus.PENDING