Skip to content
Open
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
12 changes: 12 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
23 changes: 12 additions & 11 deletions src/vouch/capture.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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")):
Expand Down Expand Up @@ -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),
}
55 changes: 55 additions & 0 deletions src/vouch/proposals.py
Original file line number Diff line number Diff line change
Expand Up @@ -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",
)
)
Comment on lines +700 to +705

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🗄️ Data Integrity & Integration | 🟠 Major | 🏗️ Heavy lift

persist the auto_approved stamp for every durable artifact.

approve() stamps only Claim; pages, entities, and relations approved here have no equivalent durable provenance field. This breaks the new default’s stated audit contract.

  • src/vouch/proposals.py#L700-L705: add persistent auto-approval provenance for page, entity, and relation approvals, including their model serialization.
  • tests/test_trusted_agent_auto_approve.py#L47-L83: assert persisted provenance for each auto-approved artifact type, including entities.
  • CHANGELOG.md#L10-L18: retain the “auto_approved stamp” claim only once non-claim artifacts carry it.
📍 Affects 3 files
  • src/vouch/proposals.py#L700-L705 (this comment)
  • tests/test_trusted_agent_auto_approve.py#L47-L83
  • CHANGELOG.md#L10-L18
🤖 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 700 - 705, Update the auto-approval flow
in src/vouch/proposals.py around approve() so page, entity, and relation
approvals persist an auto_approved provenance stamp, and extend the relevant
model serialization to include it; update
tests/test_trusted_agent_auto_approve.py lines 47-83 to verify persisted
provenance for pages, entities, relations, and claims; update CHANGELOG.md lines
10-18 so the auto_approved stamp claim remains only when all durable artifact
types support it.

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.

Expand Down
11 changes: 8 additions & 3 deletions src/vouch/storage.py
Original file line number Diff line number Diff line change
Expand Up @@ -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": {
Expand Down
4 changes: 4 additions & 0 deletions tests/test_adopt.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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)
Expand Down
4 changes: 4 additions & 0 deletions tests/test_delete.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"):
Expand Down
8 changes: 8 additions & 0 deletions tests/test_jsonl_server.py
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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")
Expand Down
166 changes: 166 additions & 0 deletions tests/test_trusted_agent_auto_approve.py
Original file line number Diff line number Diff line change
@@ -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) == []
Loading