Skip to content

State Policy

A StatePolicy is an allow/deny gate evaluated before a node's state updates are applied to AgentContext. It is the second piece of the governed execution model (after ToolPolicy).

When to use it

  • Lock down sensitive flags: forbid any node from setting payment.confirmed until a dedicated approval node runs.
  • Per-flow write scope: an "ingest" sub-graph may write draft.*, but never billing.*.
  • Argument-aware rules: refuse to set payment.amount above a threshold without explicit approval.

StatePolicy is write-only by design. Reads via AgentContext.get(StateKey<T>) are not intercepted — most production governance needs are about preventing unauthorised mutations, not blocking reads.

The SPI

@FunctionalInterface
public interface StatePolicy {
    Decision check(StateKey<?> key, @Nullable Object value);
    // factories: allowWriteKeys(...), denyWriteKeys(...), when(...), ALLOW_ALL, DENY_ALL
    // composition: policy.and(other)
}

A denied write surfaces as a StatePolicyViolation wrapped in an AgentError. The configured ErrorPolicy on the graph then decides how to react:

Error policy Behaviour on state denial
FAIL_FAST (default) Graph stops, returns the failed result with the violation as cause
RETRY_ONCE Node retried; if it still writes the denied key, fails
SKIP_NODE Node's mutations dropped, graph continues at the next edge

Attaching a policy to a graph

StatePolicy policy = StatePolicy.allowWriteKeys("draft.response", "ticket.category")
        .and(StatePolicy.denyWriteKeys("payment.confirmed"));

AgentGraph graph = AgentGraph.builder()
        .addNode("triage",  triageAgent)
        .addNode("writer",  writerAgent)
        .addEdge("triage", "writer")
        .statePolicy(policy)
        .build();

If writerAgent returns AgentResult.stateUpdates(Map.of(PAYMENT_CONFIRMED, true)), the graph runtime intercepts the mutation, replaces the outcome with a failed AgentResult carrying the StatePolicyViolation, and applies the graph's ErrorPolicy.

Argument-aware rules

StatePolicy noLargePayments = StatePolicy.when(
        (key, value) -> !(key.name().equals("payment.amount")
                && value instanceof Number n
                && n.doubleValue() > 1000.0),
        "payment.amount above 1000 requires approval");

Composition

StatePolicy allowed   = StatePolicy.allowWriteKeys("draft.response", "payment.amount");
StatePolicy guarded   = StatePolicy.when(
        (key, value) -> !(value instanceof String s && s.toLowerCase().contains("password")),
        "draft must not contain a password");
StatePolicy combined  = allowed.and(guarded);

.and(other) short-circuits on the first denial.

Default behaviour

The default policy on AgentGraph.Builder is StatePolicy.ALLOW_ALL. No interception happens — zero overhead when no policy is configured.

Reading the violation

AgentResult result = graph.invoke(ctx);
if (result.hasError() && result.error().cause() instanceof StatePolicyViolation v) {
    log.warn("Blocked write to {} = {} ({})", v.key().name(), v.value(), v.reason());
}

Relationship to ToolPolicy

ToolPolicy and StatePolicy cover different layers:

Concern Policy
What tools an agent may call ToolPolicy
What state keys an agent may write StatePolicy
What total cost a run may incur BudgetPolicy

The three compose freely on the same graph.