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
| Action | Conditions | Decision |
|---|---|---|
| action=CONFIRM | state != PROPOSED_MATCH | Deny(STATE_INVALID) |
| action=CONFIRM | proposed_by == actor.id | Deny(SOD_SAME_ACTOR) |
| action=CONFIRM | any account in always_human_accounts AND actor.kind != human | RequireHumanApproval(ACCOUNT_FORCED_HUMAN) |
| action=CONFIRM | actor.kind == engine AND amount_usd > materiality | RequireHumanApproval(MATERIALITY_OVER_THRESHOLD) |
| action=CONFIRM | actor.kind == engine AND match_type in no_auto_confirm | RequireHumanApproval(STRATEGY_REQUIRES_HUMAN) |
| action=CONFIRM | source == llm AND actor.kind == llm | Deny(SOD_SAME_ACTOR) [defense-in-depth] |
| action=CONFIRM | amount_usd > materiality AND not rationale | Deny(MISSING_RATIONALE) |
| action=REJECT | source == engine AND not rationale | Deny(MISSING_RATIONALE) |
| action=PROPOSE | (always) | Allow |
How the gate fits into a request
- The agent service receives a request from the LLM client
- It looks up the actor identity from the transport (
X-Actor-Idheader) - It packs
MatchContext+ActorContext+ the request action - It calls
gate.evaluate() - On
Allow: it commits the state change inside a transaction, writes anOKaudit row, returns success - On
RequireHumanApproval(clause): it writes aPENDINGescalation withclause.text, returns 202 + the escalation id, sends a Slack/Teams card - On
Deny(clause): it writes aPOLICY_VIOLATIONaudit row withclause.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
- The audit log — what the gate writes when it fires
- HITL approval envelopes — what happens on
RequireHumanApproval - For architects — the full architecture, MCP transport, FSM primitives
- Long-form: What is a policy gate? — the cornerstone article