← Back to Guides
2

Series

AI Control Plane· Part 2

GuideFor: AI Engineers, ML Engineers, Platform Engineers, AI Systems Architects

Global Policy Enforcement vs. Per-Agent Gate Rules: Two Layers That Must Not Collapse Into One

Treating fleet-wide policy and per-agent gate logic as the same problem is how you end up with governance theater and brittle agents at the same time.

#policy-enforcement#guardrails#langgraph#open-policy-agent#agent-fleet#production-ai#control-plane#owasp

In September 2025, Salesforce Agentforce exposed customer CRM data through a vulnerability researchers called "ForcedLeak." Malicious inputs bypassed the per-agent guardrails because those guardrails were checking the wrong thing at the wrong layer. The agent was operating correctly within its local rules. The fleet had no policy that said: data of this classification never leaves this boundary, regardless of what any individual agent decides.

That is the failure this article is about. Not a model failure. Not a prompt failure. An architectural failure - two distinct layers of control collapsed into one, and the one that survived was the wrong one.

The Conflation Problem

Here is how most teams structure policy in a multi-agent system:

Each agent has a system prompt with instructions. Some agents have middleware that blocks certain keywords. A few have human-in-the-loop gates for high-stakes tool calls. Someone wrote a validation function that checks output schemas. Call this a "guardrail stack" and ship it.

This approach has a name: per-agent gate rules. And per-agent gate rules are necessary. But they are not sufficient, and treating them as sufficient is governance theater - the appearance of control without the substance.

The thesis: global policy enforcement and per-agent gate rules are architecturally distinct layers with different owners, different change cadences, and different failure modes. When you implement only one - which most teams do - you get either a fleet that can't be governed or agents that can't be trusted.

The Harness Engineering series established what Gated Execution looks like at the individual agent level. This article is about what happens when you have 10, 20, or 50 agents running in production and need policy to hold across all of them - even when individual agents are updated, swapped, or fail.

Why the Layers Are Different

Before the implementation, the conceptual separation matters.

Per-agent gate rules answer: "Should this specific agent, in this specific context, take this specific action?"

They are agent-local. They live inside the agent's execution graph - as LangGraph node guards, LangChain middleware hooks, or inline validation in tool call handlers. They can reference agent-specific context: the current state, the session history, the output of the previous node. They change when the agent's behavior changes. A product team owns them. They are tuned for the agent's domain.

Global policy enforcement answers: "Is this action permitted anywhere in this fleet, regardless of which agent is requesting it?"

It is fleet-wide. It lives outside any individual agent - as a centralized policy decision point that every agent calls before taking actions with external impact. It cannot reference agent-internal context because it shouldn't need to: fleet policy is about what the system as a whole is allowed to do. It changes when compliance requirements change. A platform or security team owns it. It is tuned for organizational risk, not agent behavior.

I call this the Dual-Layer Gate Model: a global policy layer and a per-agent gate layer that each enforce different things, owned by different teams, with different release cadences. Collapsing them is how you get both governance gaps and over-constrained agents simultaneously.

The distinction also maps directly to the OWASP Top 10 for Agentic Applications (2026). ASI01 (Agent Goal Hijack) and ASI02 (Tool Misuse) are failures at the per-agent gate layer. ASI03 (Identity & Privilege Abuse) is a failure at the global policy layer - an agent inherited credentials it shouldn't have had, and no fleet-wide policy enforced least-privilege access across agent identities. One list, two distinct failure planes.

What Each Layer Catches - and Misses

Per-agent gates are good at catching behavioral failures specific to an agent's domain. An invoice extraction agent's gate knows that an invoice amount above $500,000 needs a second-pass validation. A customer support agent's gate knows that any response mentioning a competitor should be flagged for review. These are domain rules. They require context the global layer doesn't have.

What per-agent gates cannot catch:

  • Cross-agent data flow violations - Agent A extracts PII, passes it to Agent B, which calls an external API. The PII flows out. Agent A's gate checked its own output. Agent B's gate checked its own input. Neither gate had visibility into the end-to-end data classification of what they were routing.
  • Credential scope creep - An agent is granted write access to a production database for a legitimate use case. Six months later, that agent is reused in a different pipeline. The original credential scope travels with it. Per-agent gates don't enforce credential lifecycle.
  • Model version drift - The organization pins to a specific model version for compliance reasons. Individual agents are updated. Some agents quietly switch to a newer model. Per-agent gates don't enforce model version policy across the fleet.
  • Aggregate budget overruns - Each agent has a per-request cost limit. But the fleet runs 10,000 requests in a burst and exceeds the monthly budget cap. Per-agent gates can't see fleet-level spend.

Global policy catches all of these - if implemented correctly. But global policy misses behavioral nuance. A global policy that says "no PII in external API calls" will trigger correctly on Agent B above. But it can't distinguish between Agent C (which legitimately handles anonymized customer IDs that look like PII) and Agent D (which is actually leaking data). That distinction requires per-agent context.

This is why the Dual-Layer Gate Model requires both layers to be present and distinct. Neither is a substitute for the other.

Wrong Way: Embedding Everything in the Agent

The wrong pattern is implementing all policy inside the agent - whether in the system prompt, middleware, or graph nodes.

code
# Wrong way: policy embedded in per-agent middleware# This is governance theater. It looks like control.# It doesn't compose across a fleet.from langchain.agents.middleware import AgentMiddleware, AgentState, hook_configfrom langgraph.runtime import Runtimefrom typing import Anyclass EverythingMiddleware(AgentMiddleware):    """    Antipattern: one middleware trying to be both    per-agent gate AND global policy enforcement.    Problems:    1. Duplicated in every agent - 20 agents = 20 copies to keep in sync    2. Policy logic mixed with agent behavior logic - no separation of concerns    3. No central audit log of policy decisions    4. Changing a compliance rule requires redeploying every agent    5. No way to enforce cross-agent data flow rules from inside one agent    """    BANNED_DATA_CLASSES = ["SSN", "credit_card", "passport"]    MAX_SPEND_USD = 0.50  # per request - but who tracks fleet-level spend?    ALLOWED_MODELS = ["gpt-4o-2024-11-20"]  # duplicated in 20 agents    @hook_config(can_jump_to=["end"])    def before_agent(self, state: AgentState, runtime: Runtime) -> dict[str, Any] | None:        # Trying to do fleet-level model version enforcement per-agent        # This doesn't work if someone updates one agent's model config        # without updating all the others        model = runtime.model_name        if model not in self.ALLOWED_MODELS:            return {"messages": [{"role": "assistant", "content": "Model not permitted."}],                    "jump_to": "end"}        # Trying to do fleet-level data classification per-agent        # Agent A can't see what Agent B is sending to the same external API        content = str(state.get("messages", ""))        for data_class in self.BANNED_DATA_CLASSES:            if data_class.lower() in content.lower():                return {"messages": [{"role": "assistant", "content": "Blocked."}],                        "jump_to": "end"}        return None  # Per-request budget tracking not shown - no fleet view

The critical failure mode: when a compliance team needs to update the list of banned data classes, they have to find every agent that has a copy of this middleware, coordinate a synchronized deployment, and hope no agents were missed. In a fleet of 20 agents maintained by different teams, at least one will be out of sync. That one is the liability.

Right Way: The Dual-Layer Gate Model

The correct architecture separates policy decision from policy enforcement. The global policy layer makes fleet-wide decisions. The per-agent gate layer enforces agent-specific behavior. They communicate through a clean interface: the agent calls the policy service, the policy service returns allow/deny with a reason, the agent's gate acts on that decision.

The Global Policy Layer

Use Open Policy Agent (OPA) as the fleet-wide policy decision point. OPA is a CNCF-graduated project built exactly for this separation: policy logic lives in Rego files, versioned in Git, deployed independently of any application code. Every agent in the fleet calls the same OPA instance before any action with external impact.

code
# Global policy layer: OPA as the fleet-wide policy decision point# Rego policy lives in Git. One change deploys to all agents simultaneously.# No agent code needs to change when compliance rules change.import httpxfrom dataclasses import dataclassfrom enum import Enumfrom typing import Anyclass PolicyDecision(Enum):    ALLOW = "allow"    DENY = "deny"    REQUIRE_HUMAN = "require_human"@dataclassclass PolicyResult:    decision: PolicyDecision    reason: str    policy_version: str  # for audit trail - which policy ruled on this actionclass GlobalPolicyClient:    """    Thin client for querying the OPA fleet-wide policy decision point.    Every agent calls this before any action with external impact.    Policy logic lives in OPA - not here.    """    def __init__(self, opa_url: str = "http://opa-service:8181") -> None:        self.opa_url = opa_url    def evaluate(        self,        agent_type: str,        agent_version: str,        action: str,           # e.g. "tool_call", "external_api", "data_write"        tool_name: str,        payload: dict[str, Any],        data_classifications: list[str],  # e.g. ["PII", "CONFIDENTIAL"]        session_id: str,    ) -> PolicyResult:        """        Ask OPA: is this action permitted for this agent in this fleet?        OPA evaluates against fleet-wide policy, not agent-specific rules.        """        input_data = {            "input": {                "agent": {                    "type": agent_type,                    "version": agent_version,                },                "action": {                    "type": action,                    "tool": tool_name,                    "data_classifications": data_classifications,                },                "context": {                    "session_id": session_id,                },            }        }        response = httpx.post(            f"{self.opa_url}/v1/data/fleet/policy",            json=input_data,            timeout=0.5,  # policy checks must be fast; 500ms hard limit        )        response.raise_for_status()        result = response.json()["result"]        return PolicyResult(            decision=PolicyDecision(result["decision"]),            reason=result["reason"],            policy_version=result["policy_version"],        )

The Rego policy that OPA evaluates - version-controlled in Git, deployed independently:

code
# fleet_policy.rego# Fleet-wide policy. Owned by platform/security team.# Versioned in Git. Changes deploy to all agents simultaneously via OPA bundle.# No agent code changes required when this file changes.package fleet.policyimport rego.v1# Fleet-wide constants - single source of truthallowed_models := {    "gpt-4o-2024-11-20",    "claude-sonnet-4-6",}pii_restricted_tools := {    "send_email",    "slack_post",    "external_webhook",    "crm_write",}high_risk_actions := {    "data_write",    "external_api",}# Default deny - explicit allow requireddefault decision := "deny"default reason := "No matching allow rule"default policy_version := "1.4.2"# Rule 1: Block PII from reaching external tools (fleet-wide)# This is the rule that would have caught ForcedLeak.decision := "deny" if {    input.action.tool in pii_restricted_tools    "PII" in input.action.data_classifications    reason := "PII data class blocked from external tool by fleet policy"}# Rule 2: Enforce model version pinning across the fleetdecision := "deny" if {    not input.agent.version in allowed_models    reason := "Agent model version not in approved fleet list"}# Rule 3: High-risk actions require human review for unverified agent versionsdecision := "require_human" if {    input.action.type in high_risk_actions    not startswith(input.agent.version, "verified-")    reason := "Unverified agent version requires human approval for high-risk actions"}# Rule 4: Allow everything elsedecision := "allow" if {    not decision == "deny"    not decision == "require_human"    reason := "Permitted by fleet policy"}

The Per-Agent Gate Layer

Per-agent gates handle agent-specific behavior: domain rules, context-dependent decisions, agent-local state checks. These are LangGraph node guards or LangChain middleware that run after the global policy check - not instead of it.

code
# Per-agent gate layer: domain rules specific to the invoice processing agent# This layer runs AFTER the global policy layer clears the action.# It knows things the global policy layer doesn't: invoice amounts,# vendor trust scores, session history for this specific pipeline.from typing import Any, TypedDictfrom langgraph.graph import END, StateGraphclass InvoiceState(TypedDict):    invoice_text: str    extracted_fields: dict[str, Any]    vendor_trust_score: float    approval_status: str    session_id: str    policy_cleared: bool  # set by global policy checkdef build_invoice_gate(    policy_client: GlobalPolicyClient,    agent_identity_type: str,    agent_identity_version: str,) -> StateGraph:    """    Invoice agent with Dual-Layer Gate Model:    Layer 1 (global) - OPA fleet policy check before external actions    Layer 2 (per-agent) - domain-specific rules for invoice processing    """    def policy_gate_node(state: InvoiceState) -> InvoiceState:        """        Layer 1: Global policy check.        Runs before any external action.        Result is stored in state so downstream nodes can branch on it.        """        fields = state.get("extracted_fields", {})        # Classify data based on what the extraction agent found        data_classes = []        if "vendor_bank_account" in fields:            data_classes.append("PII")        if fields.get("amount", 0) > 100_000:            data_classes.append("HIGH_VALUE")        result = policy_client.evaluate(            agent_type=agent_identity_type,            agent_version=agent_identity_version,            action="external_api",            tool_name="crm_write",            payload=fields,            data_classifications=data_classes,            session_id=state["session_id"],        )        if result.decision == PolicyDecision.DENY:            return {                **state,                "approval_status": f"blocked_by_fleet_policy: {result.reason}",                "policy_cleared": False,            }        if result.decision == PolicyDecision.REQUIRE_HUMAN:            return {                **state,                "approval_status": "pending_human_review",                "policy_cleared": False,            }        return {**state, "policy_cleared": True}    def per_agent_gate_node(state: InvoiceState) -> InvoiceState:        """        Layer 2: Per-agent domain gate.        Only runs if global policy cleared the action.        Applies invoice-domain rules the global policy layer can't know.        """        if not state.get("policy_cleared"):            return state  # already blocked upstream - don't override        fields = state.get("extracted_fields", {})        # Domain rule 1: invoice amounts above threshold need second-pass        amount = fields.get("amount", 0)        if amount > 500_000:            return {                **state,                "approval_status": "pending_high_value_review",            }        # Domain rule 2: low vendor trust score blocks submission        trust = state.get("vendor_trust_score", 1.0)        if trust < 0.4:            return {                **state,                "approval_status": "blocked_low_vendor_trust",            }        return {**state, "approval_status": "approved"}    def route_after_policy(state: InvoiceState) -> str:        """Routes to per_agent_gate if global policy cleared, else ends."""        if not state.get("policy_cleared"):            return "end"        return "per_agent_gate"    graph = StateGraph(InvoiceState)    graph.add_node("policy_gate", policy_gate_node)    graph.add_node("per_agent_gate", per_agent_gate_node)    graph.set_entry_point("policy_gate")    graph.add_conditional_edges(        "policy_gate",        route_after_policy,        {"per_agent_gate": "per_agent_gate", "end": END},    )    graph.add_edge("per_agent_gate", END)    return graph.compile()

The separation is explicit in the graph: policy_gate runs first, then per_agent_gate. The per-agent gate checks policy_cleared before doing anything - it will not override a global policy decision.

Policy Gravity: Why the Two Layers Change at Different Speeds

I call the difference in change cadence Policy Gravity.

Global policy is heavy. It changes when compliance requirements change, when a security incident updates the threat model, when the organization adds a new data classification. These changes go through security review, legal sign-off, and coordinated deployment to OPA. They might happen quarterly. When they change, they change for every agent in the fleet simultaneously.

Per-agent gates are light. They change when agent behavior changes - when the invoice domain adds a new validation rule, when the support agent gets a new edge case, when a new vendor risk tier is introduced. These changes deploy with the agent. They might happen weekly. They affect only one agent type.

When teams collapse both layers into per-agent middleware, they get one of two failure modes. Either they treat compliance rules like agent behavior rules - deploying them per-agent, per-sprint - and a fleet of 20 agents drifts out of compliance within a month because no one coordinated the updates. Or they treat agent behavior rules like compliance rules - routing every domain change through security review - and the product team can't ship a new validation rule without a two-week approval cycle.

The Dual-Layer Gate Model keeps each layer moving at its natural speed. Rego changes go through security review because they affect everything. Middleware changes deploy with the agent because they affect only one thing.

Connecting to the Telemetry Surface Gap

From Part 1 of this series: the Telemetry Surface Gap is the coverage difference between what your monitoring captures and what you need to govern a fleet. Policy enforcement has its own version of this gap.

Every OPA policy decision should emit a structured decision log:

code
# Policy decision logging: feed into the Tier 2 harness metrics# established in Part 1 of this series.# Every policy decision is a metric event - not just a log line.from opentelemetry import metricsdef record_policy_decision(    meter: metrics.Meter,    result: PolicyResult,    agent_type: str,    action: str,    tool_name: str,    session_id: str,) -> None:    """    Emit policy decision as a typed OTel metric.    Queryable by: agent_type, decision, tool, policy_version.    This feeds the fleet-level dashboard in Grafana.    """    counter = meter.create_counter(        "gen_ai.fleet.policy_decisions",        description="Policy decisions by agent, action, and outcome",    )    counter.add(1, {        "gen_ai.agent.name": agent_type,        "fleet.policy.decision": result.decision.value,        "fleet.policy.version": result.policy_version,        "fleet.policy.action": action,        "tool.name": tool_name,        "session.id": session_id,    })

This gives you three questions answerable from a single Grafana query:

  1. What percentage of actions are being denied at the fleet-policy layer vs. the per-agent gate layer? (Tells you where your risk is actually being managed.)
  2. Which policy version triggered the most denials this week? (Tells you if a recent policy change is over-blocking legitimate agent behavior.)
  3. Which agent type is hitting the global policy boundary most frequently? (Tells you which agent needs its per-agent gate rules tightened so it stops escalating to the fleet layer.)

Diagram: Dual-Layer Gate Model

mermaid
flowchart TD
    REQ["Agent Action Request\ntool_call / external_api / data_write"]

    subgraph GlobalLayer["Layer 1: Global Policy (OPA)"]
        OPA["OPA Policy Engine\nRego rules in Git\nOwned by platform/security team"]
        GD{"Decision"}
    end

    subgraph PerAgentLayer["Layer 2: Per-Agent Gate"]
        PAG["Agent Domain Rules\nContext-aware, agent-local\nOwned by product team"]
        PAD{"Decision"}
    end

    DENY_G["Blocked\nFleet Policy Violation\nAudit log emitted"]
    HUMAN["Human Review Queue\nHigh-risk action pending"]
    DENY_A["Blocked\nAgent Domain Rule\nLocal log emitted"]
    EXEC["Action Executes\nBoth layers cleared"]

    REQ --> OPA
    OPA --> GD
    GD -->|"deny"| DENY_G
    GD -->|"require_human"| HUMAN
    GD -->|"allow"| PAG
    PAG --> PAD
    PAD -->|"deny"| DENY_A
    PAD -->|"allow"| EXEC

    style REQ fill:#95A5A6,color:#fff
    style OPA fill:#4A90E2,color:#fff
    style GD fill:#7B68EE,color:#fff
    style PAG fill:#4A90E2,color:#fff
    style PAD fill:#7B68EE,color:#fff
    style DENY_G fill:#E74C3C,color:#fff
    style HUMAN fill:#FFD93D,color:#333
    style DENY_A fill:#FFA07A,color:#fff
    style EXEC fill:#6BCF7F,color:#fff

The diagram makes the separation explicit: OPA owns the left branch (fleet-wide, organizational risk), per-agent gate owns the right branch (domain-specific, behavioral). They don't overlap. They don't substitute for each other.

What the OWASP Agentic Top 10 Says About This

The OWASP Top 10 for Agentic Applications (2026), released December 2025 with input from over 100 security researchers, maps directly to the Dual-Layer Gate Model:

OWASP RiskLayer ResponsibleWhat Catches It
ASI01 - Agent Goal HijackPer-agent gatePrompt injection filters, input sanitization at the agent boundary
ASI02 - Tool MisusePer-agent gateTool call argument validation, unsafe chaining detection
ASI03 - Identity & Privilege AbuseGlobal policyCredential scope enforcement, least-privilege rule in OPA
ASI04 - Supply Chain VulnerabilitiesGlobal policyTool registry allowlist enforced fleet-wide
ASI05 - Unexpected Code ExecutionPer-agent gate + GlobalSandboxed execution, shell tool restrictions in OPA
ASI06 - Memory & Context PoisoningPer-agent gateState validation before context injection
ASI07 - Insecure Inter-Agent CommunicationGlobal policyAgent identity verification, message signing policy

Three of the top four OWASP agentic risks - ASI02, ASI03, ASI04 - either require the global policy layer to catch them, or are substantially mitigated by it. A fleet with only per-agent gates is exposed to all three.

Implementation Checklist

Global policy layer (OPA) - before any agent goes to production:

  • OPA deployed as a dedicated service, not as a sidecar per-agent
  • Fleet-wide policy covers: allowed model versions, banned data classifications for external tools, high-risk action definitions, and agent identity allowlist
  • Rego policies are version-controlled in Git with a tagged release for each deploy
  • Every OPA decision emits a structured decision log (gen_ai.fleet.policy_decisions) queryable in your metrics backend
  • OPA policy check has a hard 500ms timeout - if OPA is unreachable, default to deny, not allow
  • Policy bundle updates are tested in a staging environment before fleet-wide rollout
  • Security/platform team owns Rego files; product teams cannot merge policy changes without review

Per-agent gate layer - before each agent type ships:

  • Per-agent gate runs after global policy check, never before
  • Gate checks policy_cleared flag before applying domain rules - does not override fleet denials
  • Domain rules are agent-specific: amounts, trust scores, context flags that OPA can't see
  • Gate decisions are logged with agent type and rule name for per-agent debugging
  • Gate rules are owned by the product team for that agent - not shared across agents
  • Per-agent gate tests include a case where global policy returns deny - verify the gate doesn't override it

Cross-layer checks:

  • Grafana dashboard shows deny rate split by layer (fleet policy vs. per-agent gate)
  • Alert if fleet-policy deny rate for any agent type exceeds a threshold - signals the agent is hitting the wrong layer for its domain rules
  • Quarterly review: which per-agent gate rules have stabilized and should graduate to fleet-wide policy?

What Comes Next

The Dual-Layer Gate Model establishes where policy decisions happen and who owns them. Part 3 of this series covers Multi-Agent Pipeline Orchestration and Failure Propagation - what happens when a policy denial in one agent needs to halt the entire downstream pipeline, and how failure signals propagate through a multi-agent graph without creating cascading timeouts or silent partial completions.

References


AI Engineering

AI Security

Agentic AI

Follow for more technical deep dives on AI/ML systems, production engineering, and building real-world applications:


Comments