diff --git a/CHANGELOG.md b/CHANGELOG.md index 48854efd..9ddd064f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,18 @@ All notable changes to vouch are documented here. Format follows ## [Unreleased] +### Changed +- **auto approval is the default** (`review.approver_role: trusted-agent` + in the starter config): a fresh KB approves the capturing agent's + proposals with no human step. nothing bypasses the gate — every write + still flows through `proposals.approve()` with one audit event and the + `auto_approved` stamp; the new `proposals.auto_approve_pending` drain + (run from capture finalize) clears claims, pages, entities and + relations, rejects duplicates, and still holds protected page kinds, + dead-reference pages, id conflicts and delete proposals for a + reviewer. remove `approver_role` from `config.yaml` to put writes back + behind `vouch review`. + ### Added - **shipped ranking champion** (`vouch.strategies.provenance`): the engine-lane winner (provenance-aware ranking — hearsay and stored diff --git a/src/vouch/capture.py b/src/vouch/capture.py index 0b6b05c1..4c6335e0 100644 --- a/src/vouch/capture.py +++ b/src/vouch/capture.py @@ -892,19 +892,20 @@ 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. +def _drain_approval_backlog(store: KBStore) -> int: + """Auto-approve whatever the configured gate allows; count it. + + Under ``review.approver_role: trusted-agent`` (the starter-config + default) this drains every pending proposal — claims, pages, relations — + left behind while the gate was stricter; under + ``review.auto_approve_on_receipt`` alone it drains receipt-verified + claims only. No-op when no gate is open, 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)) + return len(proposals_mod.auto_approve_pending(store)) except Exception: return 0 @@ -936,7 +937,7 @@ def finalize_all_except( "finalized": finalized, "skipped_recent": skipped_recent, "skipped_current": skipped_current, - "auto_approved": _drain_receipt_backlog(store), + "auto_approved": _drain_approval_backlog(store), } for path in sorted(caps_dir.glob("*.jsonl")): @@ -964,5 +965,5 @@ def finalize_all_except( "finalized": finalized, "skipped_recent": skipped_recent, "skipped_current": skipped_current, - "auto_approved": _drain_receipt_backlog(store), + "auto_approved": _drain_approval_backlog(store), } diff --git a/src/vouch/proposals.py b/src/vouch/proposals.py index 2c8feece..92326807 100644 --- a/src/vouch/proposals.py +++ b/src/vouch/proposals.py @@ -656,6 +656,61 @@ def auto_approve_receipts( return approved +def auto_approve_pending( + store: KBStore, *, actor: str | None = None +) -> list[Claim | Page | Entity | Relation]: + """Approve every pending proposal the configured gate allows. + + The full drain behind auto-approval-by-default. Under + ``review.approver_role: trusted-agent`` every pending proposal + self-approves through the normal ``approve()`` path — one audit event + per artifact, never a parallel write path. Claims go through + ``resolve_pending_receipt_claim`` so duplicates of durable claims are + closed instead of piling up; pages, entities and relations are approved + unless something still blocks them. What stays pending is exactly the + human-call residue: protected page kinds, pages with dead claim + references, an id already durable with different content, and DELETE + proposals (retracting durable knowledge is never drained mechanically). + + Without trusted-agent this falls back to the receipt drain + (``auto_approve_receipts``), which is itself a no-op when + ``review.auto_approve_on_receipt`` is off — the review gate is honoured, + never silently bypassed. + """ + review_cfg = _review_config(store) + if review_cfg.get("approver_role") != "trusted-agent": + return list(auto_approve_receipts(store, actor=actor)) + approved: list[Claim | Page | Entity | Relation] = [] + for proposal in store.list_proposals(ProposalStatus.PENDING): + if proposal.kind == ProposalKind.DELETE: + continue + if proposal.kind == ProposalKind.CLAIM: + claim = resolve_pending_receipt_claim( + store, proposal, + actor=actor or proposal.proposed_by, + reason="trusted-agent — auto-approved", + ) + if claim is not None: + approved.append(claim) + continue + approver = actor or proposal.proposed_by + if check_approvable(store, proposal.id, approved_by=approver) is not None: + continue + try: + approved.append( + approve( + store, proposal.id, approved_by=approver, + reason="trusted-agent — auto-approved", + ) + ) + except ProposalError: + # check_approvable is a dry-run; the write itself can still fail + # (e.g. a claim ref deleted between check and approve). Left + # pending for a human, the drain survives. + continue + return approved + + def _payload_block_reason(store: KBStore, proposal: Proposal) -> str | None: """Dry-run the put_*-side ref guards, return reason string or None. diff --git a/src/vouch/storage.py b/src/vouch/storage.py index 7a57b8eb..b20f2ecb 100644 --- a/src/vouch/storage.py +++ b/src/vouch/storage.py @@ -81,11 +81,16 @@ def _starter_config() -> dict[str, Any]: "review": { "require_human_approval": False, "expire_pending_after_days": 90, + # auto approval is the default: the capturing agent's proposals + # self-approve through the same proposals.approve() gate (one + # audit event per artifact, duplicates rejected, protected page + # kinds and DELETE proposals still held for a reviewer). Remove + # approver_role to put every other write behind `vouch review`. + "approver_role": "trusted-agent", # 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, as is - # every page/entity/relation proposal. Set false to put every - # write behind `vouch review`. + # a claim that cannot quote its source is left pending. Only + # consulted when approver_role does not already clear the write. "auto_approve_on_receipt": True, }, "capture": { diff --git a/tests/test_adopt.py b/tests/test_adopt.py index 203f01d6..9a24df0d 100644 --- a/tests/test_adopt.py +++ b/tests/test_adopt.py @@ -126,6 +126,7 @@ def test_adopt_respects_a_closed_gate(personal: KBStore, tmp_path: Path) -> None project = KBStore.init(origin) cfg = yaml.safe_load(project.config_path.read_text(encoding="utf-8")) cfg["review"]["auto_approve_on_receipt"] = False + cfg["review"].pop("approver_role", None) project.config_path.write_text(yaml.safe_dump(cfg), encoding="utf-8") report = adopt_mod.adopt(project, personal, match_root=origin) @@ -284,6 +285,7 @@ def test_adopt_does_not_requeue_pending_claims( project = KBStore.init(origin) cfg = yaml.safe_load(project.config_path.read_text(encoding="utf-8")) cfg["review"]["auto_approve_on_receipt"] = False + cfg["review"].pop("approver_role", None) project.config_path.write_text(yaml.safe_dump(cfg), encoding="utf-8") first = adopt_mod.adopt(project, personal, match_root=origin) @@ -364,6 +366,7 @@ def test_retire_never_archives_a_claim_that_only_landed_pending( project = KBStore.init(origin) cfg = yaml.safe_load(project.config_path.read_text(encoding="utf-8")) cfg["review"]["auto_approve_on_receipt"] = False + cfg["review"].pop("approver_role", None) project.config_path.write_text(yaml.safe_dump(cfg), encoding="utf-8") report = adopt_mod.adopt(project, personal, match_root=origin, retire=True) @@ -395,6 +398,7 @@ def test_dry_run_honours_a_closed_gate(personal: KBStore, tmp_path: Path) -> Non project = KBStore.init(origin) cfg = yaml.safe_load(project.config_path.read_text(encoding="utf-8")) cfg["review"]["auto_approve_on_receipt"] = False + cfg["review"].pop("approver_role", None) project.config_path.write_text(yaml.safe_dump(cfg), encoding="utf-8") dry = adopt_mod.adopt(project, personal, match_root=origin, dry_run=True) diff --git a/tests/test_delete.py b/tests/test_delete.py index 31b5d7de..9c107455 100644 --- a/tests/test_delete.py +++ b/tests/test_delete.py @@ -307,6 +307,10 @@ def test_approve_delete_idempotent_path_deindexes(store: KBStore) -> None: def test_delete_forbids_self_approval(store: KBStore) -> None: + # pin the gate closed: the starter config trusts the agent by default. + store.config_path.write_text( + "review:\n auto_approve_on_receipt: false\n", encoding="utf-8" + ) _claim(store, "c1") pr = propose_delete(store, target_kind="claim", target_id="c1", proposed_by="same") with pytest.raises(ProposalError, match="forbidden_self_approval"): diff --git a/tests/test_jsonl_server.py b/tests/test_jsonl_server.py index a2dfaada..7db3e97a 100644 --- a/tests/test_jsonl_server.py +++ b/tests/test_jsonl_server.py @@ -203,6 +203,10 @@ def test_jsonl_session_lifecycle(store: KBStore, monkeypatch) -> None: def test_jsonl_self_approval_forbidden(store: KBStore, monkeypatch) -> None: """approve() must raise forbidden_self_approval when proposer == approver.""" + # pin the gate closed: the starter config trusts the agent by default. + store.config_path.write_text( + "review:\n auto_approve_on_receipt: false\n", encoding="utf-8" + ) src = store.put_source(b"evidence") monkeypatch.chdir(store.root) pr = handle_request({"id": "1", "method": "kb.propose_claim", @@ -298,6 +302,10 @@ def test_one_token_cannot_self_approve_by_swapping_header( from vouch import jsonl_server from vouch import trust as trust_mod + # pin the gate closed: the starter config trusts the agent by default. + store.config_path.write_text( + "review:\n auto_approve_on_receipt: false\n", encoding="utf-8" + ) src = store.put_source(b"evidence") monkeypatch.chdir(store.root) trust = trust_mod.with_auth_subject(trust_mod.JSONL_HTTP, "one-token") diff --git a/tests/test_trusted_agent_auto_approve.py b/tests/test_trusted_agent_auto_approve.py new file mode 100644 index 00000000..4b05e558 --- /dev/null +++ b/tests/test_trusted_agent_auto_approve.py @@ -0,0 +1,166 @@ +"""Trusted-agent auto-approval: the full drain behind auto-approval-by-default. + +With ``review.approver_role: trusted-agent`` (the starter-config default), +``auto_approve_pending`` approves every pending proposal through the normal +``approve()`` path — claims, pages, entities, relations. What stays pending is +the human-call residue: protected page kinds, DELETE proposals, and id +conflicts. Without trusted-agent the drain degrades to the receipt gate, and +with no gate open it is a no-op — the review gate is never silently bypassed. +""" + +from __future__ import annotations + +from pathlib import Path + +import pytest + +from vouch.models import ProposalStatus +from vouch.proposals import ( + auto_approve_pending, + propose_claim, + propose_delete, + propose_page, + propose_quoted_claim, + propose_relation, +) +from vouch.storage import KBStore + + +@pytest.fixture +def store(tmp_path: Path) -> KBStore: + return KBStore.init(tmp_path) + + +def _set_review(store: KBStore, review_yaml: str) -> None: + store.config_path.write_text(review_yaml, encoding="utf-8") + + +def test_starter_config_defaults_to_trusted_agent(store: KBStore) -> None: + # a fresh kb ships with auto approval on: no config edit, no human step. + import yaml + + loaded = yaml.safe_load(store.config_path.read_text(encoding="utf-8")) + assert loaded["review"]["approver_role"] == "trusted-agent" + assert loaded["review"]["auto_approve_on_receipt"] is True + + +def test_trusted_agent_drains_all_kinds(store: KBStore) -> None: + src = store.put_source(b"alpha beta gamma") + receipted = propose_quoted_claim( + store, text="mentions beta", source_id=src.id, quote="beta", + proposed_by="agent-a", + ) + assert receipted is not None + bare = propose_claim( + store, text="bare assertion", evidence=[src.id], proposed_by="agent-a", + ) + page = propose_page( + store, title="session notes", body="what happened", + source_ids=[src.id], proposed_by="agent-a", page_type="session", + ) + + approved = auto_approve_pending(store) + + # trusted-agent clears everything: the unreceipted claim and the page + # too, not just the receipt-verified claim. + assert len(approved) == 3 + assert store.get_proposal(receipted.id).status is ProposalStatus.APPROVED + assert store.get_proposal(bare.id).status is ProposalStatus.APPROVED + assert store.get_proposal(page.id).status is ProposalStatus.APPROVED + + +def test_trusted_agent_drains_relations(store: KBStore) -> None: + src = store.put_source(b"alpha beta gamma") + a = propose_claim(store, text="claim a", evidence=[src.id], proposed_by="x") + b = propose_claim(store, text="claim b", evidence=[src.id], proposed_by="x") + assert len(auto_approve_pending(store)) == 2 + rel = propose_relation( + store, src=str(a.proposal.payload["id"]), relation="supports", + target=str(b.proposal.payload["id"]), proposed_by="x", + ) + approved = auto_approve_pending(store) + assert len(approved) == 1 + assert store.get_proposal(rel.id).status is ProposalStatus.APPROVED + + +def test_protected_page_kind_stays_pending(store: KBStore) -> None: + _set_review( + store, + "review:\n approver_role: trusted-agent\n" + "page_kinds:\n decision:\n protected: true\n", + ) + page = propose_page( + store, title="a decision", body="we decided", proposed_by="agent-a", + page_type="decision", + ) + assert auto_approve_pending(store) == [] + assert store.get_proposal(page.id).status is ProposalStatus.PENDING + + +def test_delete_proposals_never_drained(store: KBStore) -> None: + src = store.put_source(b"alpha beta gamma") + filed = propose_claim( + store, text="to be deleted", evidence=[src.id], proposed_by="x", + ) + assert len(auto_approve_pending(store)) == 1 + deletion = propose_delete( + store, target_kind="claim", target_id=str(filed.proposal.payload["id"]), + proposed_by="x", + ) + assert auto_approve_pending(store) == [] + assert store.get_proposal(deletion.id).status is ProposalStatus.PENDING + + +def test_duplicate_claim_rejected_not_repiled(store: KBStore) -> None: + src = store.put_source(b"alpha beta gamma") + propose_quoted_claim( + store, text="mentions beta", source_id=src.id, quote="beta", + proposed_by="agent-a", + ) + assert len(auto_approve_pending(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_pending(store) == [] + decided = store.get_proposal(again.id) + assert decided.status is ProposalStatus.REJECTED + assert "duplicate" in (decided.decision_reason or "") + + +def test_falls_back_to_receipt_drain_without_trusted_agent(store: KBStore) -> None: + _set_review(store, "review:\n auto_approve_on_receipt: true\n") + src = store.put_source(b"alpha beta gamma") + good = propose_quoted_claim( + store, text="mentions beta", source_id=src.id, quote="beta", + proposed_by="agent-a", + ) + assert good is not None + bare = propose_claim( + store, text="bare assertion", evidence=[src.id], proposed_by="agent-a", + ) + page = propose_page( + store, title="session notes", body="what happened", + source_ids=[src.id], proposed_by="agent-a", page_type="session", + ) + approved = auto_approve_pending(store) + # receipt gate only: the verified claim drains, everything else pends. + assert len(approved) == 1 + assert store.get_proposal(good.id).status is ProposalStatus.APPROVED + assert store.get_proposal(bare.id).status is ProposalStatus.PENDING + assert store.get_proposal(page.id).status is ProposalStatus.PENDING + + +def test_noop_when_no_gate_open(store: KBStore) -> None: + _set_review(store, "review:\n auto_approve_on_receipt: false\n") + src = store.put_source(b"alpha beta gamma") + propose_quoted_claim( + store, text="mentions beta", source_id=src.id, quote="beta", + proposed_by="agent-a", + ) + propose_page( + store, title="session notes", body="what happened", + source_ids=[src.id], proposed_by="agent-a", page_type="session", + ) + assert auto_approve_pending(store) == []