Concept

The policy gate.

Every state-changing tool call from your AI agent passes through one transactional gate. It returns Allow, RequireHumanApproval(clause), or Deny(clause). That's the whole interface.

The policy gate is the architectural commitment that makes closegate defensible to your audit committee. Three properties:

It's a pure function

evaluate(action, match, actor, accounts, rationale, config)Allow | RequireHumanApproval(clause) | Deny(clause). No I/O, no global state, no async. Deterministic given inputs. Unit-testable in isolation. Lives in closegate_policy.gate, ~200 lines.

It's the only mutation API

There is no second confirmation API anywhere in the engine. Every workflow transition (match confirm, AP approve, payment submit, period close) routes through the same gate. Adding a new MCP tool requires a tier annotation; the annotation is the routing rule.

It carries verbatim policy clause text

When the gate denies or routes to HITL, the resulting event in the audit log carries the exact text of the rule from your policy.yaml plus a JSON-pointer to the rule. An external auditor reads the audit log and quotes the text verbatim in the PBC bundle. No vendor-mediated translation layer.

The decision matrix

ActionConditionsDecision
action=CONFIRMstate != PROPOSED_MATCHDeny(STATE_INVALID)
action=CONFIRMproposed_by == actor.idDeny(SOD_SAME_ACTOR)
action=CONFIRMany account in always_human_accounts AND actor.kind != humanRequireHumanApproval(ACCOUNT_FORCED_HUMAN)
action=CONFIRMactor.kind == engine AND amount_usd > materialityRequireHumanApproval(MATERIALITY_OVER_THRESHOLD)
action=CONFIRMactor.kind == engine AND match_type in no_auto_confirmRequireHumanApproval(STRATEGY_REQUIRES_HUMAN)
action=CONFIRMsource == llm AND actor.kind == llmDeny(SOD_SAME_ACTOR) [defense-in-depth]
action=CONFIRMamount_usd > materiality AND not rationaleDeny(MISSING_RATIONALE)
action=REJECTsource == engine AND not rationaleDeny(MISSING_RATIONALE)
action=PROPOSE(always)Allow

How the gate fits into a request

  1. The agent service receives a request from the LLM client
  2. It looks up the actor identity from the transport (X-Actor-Id header)
  3. It packs MatchContext + ActorContext + the request action
  4. It calls gate.evaluate()
  5. On Allow: it commits the state change inside a transaction, writes an OK audit row, returns success
  6. On RequireHumanApproval(clause): it writes a PENDING escalation with clause.text, returns 202 + the escalation id, sends a Slack/Teams card
  7. On Deny(clause): it writes a POLICY_VIOLATION audit row with clause.text, raises an HTTP 403, surfaces the rule text to the caller

The agent service never makes the policy decision itself. The MCP server never makes it. The decision happens in one place, deterministically, with full audit attribution. That's the chokepoint.

Configuring the gate

PolicyConfig is the typed configuration that's loaded once from policy.yaml at startup:

  • materiality_threshold_usd: global threshold for auto-confirm
  • always_human_accounts: frozenset[str] of account IDs that force HITL regardless of materiality
  • no_auto_confirm_match_types: frozenset[str] of match strategies that route to HITL (e.g. fuzzy_match)
  • clauses: dict[PolicyReason, PolicyClause] — the verbatim text + JSON-pointer for each rule
  • strategy_clauses_by_match_type: per-match-type clause text for STRATEGY_REQUIRES_HUMAN
  • entity_materiality_overrides: dict[str, Decimal] — per-entity-id materiality (US parent $10K, JP sub ¥1M, IN sub ₹500K)

11 starter policy.yaml libraries ship covering US / UK / EU / JP / IN / AU / CA + holdco + SOX + fintech + SaaS. Fork from the closest starter and adapt.

Adjacent reading

Inbound

Talk to the maintainer

Two design-partner slots open this quarter. One real workflow, your real policy.yaml, monthly 30-min call, direct line. Apache-2.0, self-hosted, no seat licensing — forever.