Skip to content

feat: review-gate policy engine — conditional auto-approve, block, and escalation rules [VEP] #162

Description

@dale053

What you're trying to do

When operating in a multi-agent or team environment, a single approver_role: human flag is far too coarse.
Today every proposal — whether it is a trivial fact Claim filed by a well-known trusted agent or a new Entity proposed by an unknown actor — goes into the same pending queue and waits for a human to act on it.

In practice, three patterns come up constantly:

  1. Trusted-agent fast-path. A well-configured agent (e.g. claude-code) files a fact Claim that cites an existing, verified Source. There is no reason to block it; requiring a human round-trip actively slows the agent down.
  2. Escalation by kind or confidence. A decision Claim with confidence < 0.7, or any Claim with supersedes set, is high-stakes and should always require explicit human sign-off regardless of who filed it.
  3. Redaction / quarantine. An anonymous or untrusted actor MUST NOT be able to land any durable artifact without explicit approval.

Right now there is no way to express these rules; every deployment uses the same binary human | trusted-agent knob.

What you've tried

  • review.approver_role: trusted-agent in config.yaml — this auto-approves everything for the configured agent, with no conditions on claim type, confidence, or evidence quality.
  • vouch approve --batch (PR Feat/batch approve proposals #111) — speeds up human review but does not eliminate the queue.
  • vouch pending --json + shell scripting — possible but fragile; the logic lives outside the KB and does not appear in the audit log.

Suggested shape

1. config.yaml — new review.policy stanza

review:
  require_citations: true
  approver_role: human          # kept as a fallback default

  policy:
    default: require_human      # require_human | auto_approve | block
    rules:
      - name: trusted-agent-facts
        when:
          actor: ["claude-code", "codex-agent"]
          kind: claim
          claim_type: [fact, observation, preference]
          min_confidence: 0.8
          has_evidence: true
        action: auto_approve

      - name: high-stakes-decisions
        when:
          kind: claim
          claim_type: [decision, warning]
        action: require_human

      - name: supersede-always-human
        when:
          payload_has: supersedes
        action: require_human

      - name: low-confidence-block
        when:
          max_confidence: 0.4
        action: block
        reason: "Confidence too low to land without manual evidence review."

      - name: unknown-actor
        when:
          actor_not_in: ["claude-code", "codex-agent", "human"]
        action: require_human

Rules are evaluated top-to-bottom; first match wins. default fires when no rule matches.


2. src/vouch/policy.py — new module

@dataclass
class PolicyRule:
    name: str
    when: RuleCondition
    action: Literal["auto_approve", "require_human", "block"]
    reason: str | None = None

@dataclass
class ReviewPolicy:
    default: Literal["auto_approve", "require_human", "block"]
    rules: list[PolicyRule]

    def evaluate(self, proposal: Proposal) -> PolicyDecision: ...

PolicyDecision carries action, matched_rule_name | None, and reason.


3. Model changes — src/vouch/models.py

  • Config gains a policy: ReviewPolicy | None field (optional; absence keeps current behaviour).
  • AuditEvent gains policy_rule: str | None — the name of the rule that fired, or None for manual decisions.
  • Proposal gains auto_approved: bool and policy_rule: str | None on the decided record so the review-gate decision is traceable.

4. src/vouch/proposals.py — integrate policy at write time

file_proposal() calls policy.evaluate() immediately after validation:

  • block → raise PolicyBlockedError (new domain error), the proposal is never written to proposed/.
  • auto_approve → call approve() inline with actor="policy-engine", write directly to durable storage and decided/.
  • require_human → current behaviour (write to proposed/, surface in vouch pending).

5. src/vouch/audit.py — new event types

proposal.auto_approved   — fired when policy engine auto-approves
proposal.policy_blocked  — fired when policy engine blocks at file time

Both events carry policy_rule in their data dict.


6. CLI changes — src/vouch/cli.py

vouch policy check <proposal-yaml>
Dry-run evaluation: given a YAML file in proposal format (or a proposal id), prints which rule would fire and what action would result. Exit code 0 = would pass, 2 = would block.

$ vouch policy check .vouch/proposed/claim-abc123.yaml
rule:   trusted-agent-facts
action: auto_approve
reason: actor=claude-code, kind=claim, claim_type=fact, confidence=0.92, has_evidence=true

vouch policy lint
Validates the review.policy stanza in config.yaml — detects unreachable rules, conflicting conditions, missing reason on block actions, etc. Integrates into vouch lint.

vouch pending --show-rule
Adds a policy_rule column to vouch pending output (and --json equivalent) so humans can see why each proposal ended up in the queue.

vouch status
Adds auto_approved_today, blocked_today counts alongside existing pending count.


7. MCP / JSONL server — src/vouch/server.py, src/vouch/jsonl_server.py

  • kb.capabilities response gains a policy capability descriptor:
    "policy": {
      "enabled": true,
      "auto_approve_actions": ["fact", "observation"],
      "default_action": "require_human"
    }
  • kb.propose_* tools surface PolicyBlockedError as a structured error response (code: "policy_blocked", rule, reason) so agents can handle it programmatically rather than receiving an unstructured exception.
  • New read-only tool kb.policy_check — evaluates a prospective proposal payload against the current policy without filing it. Returns {action, rule, reason}. Useful for agents to pre-flight before committing a proposal.

8. Schemas — schemas/

Four schema files need updating or creation:

File Change
schemas/config.schema.json Add review.policy with PolicyRule[] shape
schemas/proposal.schema.json Add auto_approved: bool, policy_rule: string|null
schemas/audit-event.schema.json Add policy_rule: string|null to data
schemas/capabilities.schema.json Add policy capability descriptor
schemas/policy-rule.schema.json New — standalone schema for a single PolicyRule
schemas/policy-decision.schema.json New — standalone schema for PolicyDecision

9. Tests — tests/

Test file Coverage
tests/test_policy.py Unit-test PolicyRule.evaluate(), condition matching, rule ordering, default fallback, block raises
tests/test_proposals.py (extend) Integration: auto_approve path lands in durable storage; block never writes proposed/; audit events fire
tests/test_cli.py (extend) vouch policy check, vouch policy lint, --show-rule flags
tests/test_server.py (extend) kb.policy_check tool; kb.propose_claim returns structured policy_blocked error
tests/test_jsonl_server.py (extend) JSONL parity: policy_blocked error shape, kb.policy_check round-trip
tests/test_audit.py (extend) proposal.auto_approved and proposal.policy_blocked events written correctly

10. Documentation

File Change
docs/review-gate.md New section: Policy Engine — rule syntax, evaluation order, auto_approve / block / require_human semantics
docs/multi-agent.md Show policy config for a multi-agent deployment with mixed trust levels
docs/object-model.md Document auto_approved and policy_rule fields on Proposal and AuditEvent
SPEC.md Extend §1.1 (config.yaml), §3 (review-gate state machine), §4 (audit log)

A new VEP-0006-review-gate-policy-engine.md is needed in proposals/ before this lands (surface change: new config stanza, new method kb.policy_check, new fields on Proposal and AuditEvent).


Compatibility considerations

This is additive at the config level: if no review.policy stanza is present, behaviour is identical to today. Existing KBs need no migration.

It is a surface change in two ways:

  1. New kb.policy_check method on the kb.* surface → VEP required before implementation.
  2. New fields on Proposal (auto_approved, policy_rule) and AuditEvent (policy_rule) → schema version bump; old readers ignore unknown fields (pydantic extra = "ignore"), so backwards-compatible.

No on-disk layout changesdecided/ YAML files gain optional fields; proposed/ files are unaffected.

The vouch migrate infrastructure (PR #108) would need to carry a migration step that backfills auto_approved: false on existing decided records (cosmetic; not required for correctness).

Alternatives

  1. Shell-script wrapper around vouch approve — logic lives outside the KB, not audited, breaks on concurrent agents.
  2. Extend approver_role to a list of trusted agents — covers the fast-path case only; cannot express condition-based escalation or blocking.
  3. Per-proposal annotations in rationale — agents can self-declare trustworthiness in the proposal text, but there is no enforcement.
  4. OAuth / RBAC at the transport layer (HTTP bearer tokens, PR feat(serve): add HTTP/SSE transport with bearer-token auth #154) — controls who can call kb.propose_* but not what kinds of proposals auto-land vs. require review.

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't working

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions