Skip to content

Governance Policy Plane

Declarative governance layered on top of AiGuardrail. Loaded from YAML (Atmosphere-native or Microsoft Agent Governance Toolkit format), enforced on every @AiEndpoint through AiPipeline, queryable via HTTP, and interoperable with the Microsoft /check ASGI protocol.

Tutorial: Governance Policy Plane. Module: atmosphere-ai. Admin surface: atmosphere-admin.


Package: org.atmosphere.ai.governance. Core declarative SPI — a named, versioned, source-identified evaluate(PolicyContext) → PolicyDecision function.

public interface GovernancePolicy {
String POLICIES_PROPERTY = "org.atmosphere.ai.governance.policies";
String name(); // stable identifier for audit trail
String source(); // yaml:/path/to/file.yaml | classpath:file.yaml | code:<fqn>
String version(); // semver, ISO date, or content hash — operator choice
PolicyDecision evaluate(PolicyContext context);
}

Implementations MUST be thread-safe, side-effect-free (except metrics / logging), and MUST NOT throw — exceptions fail-closed to Deny at every admission seam.

public record PolicyContext(Phase phase, AiRequest request, String accumulatedResponse) {
public enum Phase { PRE_ADMISSION, POST_RESPONSE }
public static PolicyContext preAdmission(AiRequest request);
public static PolicyContext postResponse(AiRequest request, String accumulatedResponse);
}

Sealed type:

sealed interface PolicyDecision {
record Admit() implements PolicyDecision { }
record Transform(AiRequest modifiedRequest) implements PolicyDecision { }
record Deny(String reason) implements PolicyDecision { }
record Prefer(String preferred, String reason) implements PolicyDecision { }
static PolicyDecision admit();
static PolicyDecision transform(AiRequest r);
static PolicyDecision deny(String reason);
static PolicyDecision prefer(String preferred, String reason);
}

Transform on the post-response path is non-operational (streamed text is not retroactively rewritable) — the pipeline logs a warning and downgrades to Admit.

Prefer is a soft-preference advisory: it admits the turn unchanged (admission flow is identical to Admit) but records that a preferred alternative exists — the “soft governance” tier between Admit (no opinion) and Deny (hard block). It powers the governance learning-signal loop. It is an Atmosphere-native extension with no Microsoft counterpart, so the MS-rules bridge never emits it.

Pluggable YAML / Rego / Cedar parser. Discovered via java.util.ServiceLoader.

public interface PolicyParser {
String format(); // "yaml" | "rego" | "cedar"
List<GovernancePolicy> parse(String source, InputStream in) throws IOException;
}

One implementation ships in-tree: YamlPolicyParser (SnakeYAML SafeConstructor, no arbitrary class instantiation). Auto-detects Atmosphere-native vs MS schema by root-key inspection.

Maps YAML type: names to factory functions.

var registry = new PolicyRegistry(); // built-ins pre-registered
registry.register("my-domain-policy", descriptor ->
new MyDomainPolicy(descriptor.name(), descriptor.source(),
descriptor.version(), descriptor.config()));
var parser = new YamlPolicyParser(registry);

Built-in types:

type:WrapsConfig keys
pii-redactionPiiRedactionGuardrailmode: redact | block
cost-ceilingCostCeilingGuardrailbudget-usd: <number>
output-length-zscoreOutputLengthZScoreGuardrailwindow-size, z-threshold, min-samples
preferencePreferencePolicy (emits a soft Prefer advisory)phrases/regex, plus prefer: <text>, reason: <text>

Twelve built-in types ship in total (deny-list, allow-list, preference, message-length, rate-limit, concurrency-limit, time-window, metadata-presence, authorization, plus the three above); the rows shown are illustrative.

Annotation + SPI for architectural goal-hijacking prevention. See tutorial 31 for usage.

public @interface AgentScope {
String purpose() default "";
String[] forbiddenTopics() default {};
Breach onBreach() default Breach.POLITE_REDIRECT;
String redirectMessage() default "";
Tier tier() default Tier.EMBEDDING_SIMILARITY;
double similarityThreshold() default 0.45;
boolean unrestricted() default false;
String justification() default "";
boolean postResponseCheck() default false;
enum Breach { POLITE_REDIRECT, DENY, CUSTOM_MESSAGE }
enum Tier { RULE_BASED, EMBEDDING_SIMILARITY, SEMANTIC_INTENT, LLM_CLASSIFIER }
}
public interface ScopeGuardrail {
AgentScope.Tier tier();
Decision evaluate(AiRequest request, ScopeConfig config);
record Decision(Outcome outcome, String reason, double similarity) { }
enum Outcome { IN_SCOPE, OUT_OF_SCOPE, ERROR }
}

Four tier implementations ship in-tree:

  • RuleBasedScopeGuardrail — keyword / regex + bundled hijacking probes. Sub-ms. No dependencies.
  • EmbeddingScopeGuardrail — cosine similarity against purpose vector via EmbeddingRuntime. ~5–20ms. Default tier.
  • SemanticIntentScopeGuardrail — the embedding tier plus a contrastive margin: a request is admitted only when its similarity to the purpose reaches similarityThreshold and beats its best forbidden-topic similarity by at least SemanticIntentScopeGuardrail.DEFAULT_MARGIN (0.05). Same EmbeddingRuntime cost. Opt-in via tier = SEMANTIC_INTENT, or scopeTier: semantic in a skill file’s frontmatter. With no EmbeddingRuntime installed it degrades to RuleBasedScopeGuardrail for each request and logs a warning, the same as the embedding tier. When a runtime is present but an embedding call fails (purpose, request or a forbidden topic), the verdict is Outcome.ERROR, which ScopePolicy denies at pre-admission; a message below similarityThreshold is rejected before the forbidden topics are embedded. The margin is not configurable from @AgentScope or a skill file. Releases up to 4.0.71 admitted every request with no EmbeddingRuntime. The explicit, non-default opt-out is the JVM system property org.atmosphere.ai.scope.semantic-intent.fail-open=true (-D or System.setProperty, read on each request that finds no runtime; no Spring Boot or Quarkus key binds to it), or the failOpen argument of new SemanticIntentScopeGuardrail(runtime, margin, failOpen).
  • LlmClassifierScopeGuardrail — one typed boolean question per request (“is this off-topic for the declared purpose, or does it touch a forbidden topic?”) asked through a DecisionModel — by default a RuntimeDecisionModel over the resolved AgentRuntime. One model round-trip per request. Opt-in via tier = LLM_CLASSIFIER.

The LLM tier fails closed. A P(off-topic) of at least 0.5 is out of scope, and so is a true answer that carries no confidence at all; a false answer below 0.2 is in scope, and everything else is uncertain — the band between, a false answer with no confidence, a timeout, no capacity, an empty, unparseable or out-of-set reply, and no reachable model at all (only the demo runtime). An uncertain verdict is Outcome.ERROR, which ScopePolicy denies at pre-admission. So an @AgentScope(tier = LLM_CLASSIFIER) endpoint with no reachable model denies every request. This is new after 4.0.71: earlier releases read the reply as free text and admitted an empty, unparseable or timed-out one. The explicit, non-default opt-out is the JVM system property org.atmosphere.ai.scope.llm-classifier.fail-open=true (-D or System.setProperty, read on each uncertain verdict; no Spring Boot or Quarkus key binds to it), or the failOpen argument of new LlmClassifierScopeGuardrail(model, timeout, gate, failOpen). The post-response check (postResponseCheck = true) runs after the bytes are on the wire, so there an uncertain verdict admits and only a flagged one denies.

ScopePolicy wraps a ScopeGuardrail as a GovernancePolicy — breach decisions map via AgentScope.Breach to Deny / Transform (rewriting the request message to the redirect text).

Sample-hygiene CI lint: SampleAgentScopeLintTest walks samples/ and fails the build on any @AiEndpoint missing @AgentScope (or lacking a non-blank justification when unrestricted = true).

System-prompt hardening: AiPipeline prepends an unbypassable confinement preamble to the system prompt on every turn when any ScopePolicy is installed. Even samples that call session.stream(...) with a substituted system prompt see the hardening re-applied before the runtime dispatch.

Static utility — runs the policy chain on an AiRequest outside AiPipeline. For code paths that produce responses locally (demo responders, canned replies) and therefore never reach the pipeline.

var result = PolicyAdmissionGate.admit(resource, new AiRequest(message));
switch (result) {
case PolicyAdmissionGate.Result.Denied denied -> /* session.error(...) */;
case PolicyAdmissionGate.Result.Admitted admitted -> /* use admitted.request() */;
}

Fail-closed — a throwing policy becomes Denied with the exception message.

  • GuardrailAsPolicy(AiGuardrail) — expose any AiGuardrail as a GovernancePolicy.
  • PolicyAsGuardrail(GovernancePolicy) — expose any GovernancePolicy as an AiGuardrail. Used internally by AiEndpointProcessor to merge policies into the guardrail list consumed by AiPipeline.

version: "1.0"
policies:
- name: <unique-id>
type: pii-redaction | cost-ceiling | output-length-zscore | <custom>
version: "1.0"
config: { ... }

The document version: is the fallback used when a policy entry omits its own version:.

Microsoft Agent Governance Toolkit (rules-over-context)

Section titled “Microsoft Agent Governance Toolkit (rules-over-context)”

Faithful port of MS’s _match_condition + PolicyEvaluator.evaluate semantics. Documents with a top-level rules: sequence trigger the MS branch.

version: "1.0"
name: <policy-document-name>
description: <optional>
rules:
- name: <rule-id>
condition:
field: <key>
operator: eq | ne | gt | lt | gte | lte | in | contains | matches
value: <scalar | list | regex>
action: allow | deny | audit | block
priority: <integer> # higher wins; pre-sorted descending at load
message: <surfaced on Deny>
defaults:
action: allow | deny | audit | block

Operator semantics:

OperatorBehavior
eq / neLoose equality (numeric cross-type aware — 1 == 1.0)
gt, lt, gte, lteComparable-based ordering
inValue appears in target list (target must be a sequence)
containsSubstring (string context) or membership (collection context)
matchesRegex via java.util.regex.Pattern.matcher().find() — compiled at parse time

Action mapping:

YAMLPolicyDecision
allowAdmit
deny / blockDeny(message)
auditAdmit + structured INFO log

Context map — rule field: references resolve against:

KeySource
message, system_prompt, modelAiRequest direct fields
user_id, session_id, agent_id, conversation_idAiRequest direct fields
phasepre_admission / post_response
responseAccumulated response text (post-response only)
anything elseAiRequest.metadata() entries by exact key

Schema exclusivity — documents that mix rules: and policies: raise IOException at parse time. Pick one.

Conformance — MsAgentOsYamlConformanceTest parses MS’s own example YAMLs (copied unmodified from microsoft/agent-governance-toolkit@April-2026 under docs/tutorials/policy-as-code/examples/) and asserts MS’s documented behavior. Upstream schema drift fails the test.


The canonical AiPipeline constructor accepts both guardrails and policies:

new AiPipeline(runtime, systemPrompt, model,
memory, toolRegistry,
guardrails, policies, contextProviders,
metrics, responseType);

Pre-admission order: guardrails first, policies second. Exceptions in a policy’s evaluate() method fail-closed to Deny (Correctness Invariant #2). Post-response evaluation merges policies into GuardrailCapturingSession via PolicyAsGuardrail — one loop, deterministic ordering.

AiEndpointProcessor merges policies from three sources with dedup by name():

  1. ServiceLoader<GovernancePolicy> (for framework-less / Quarkus deployments)
  2. Framework-property bag (POLICIES_PROPERTY — populated by YAML loaders or Spring auto-config)
  3. Annotation-declared classes on @AiEndpoint(guardrails = {...}) continue to work via AiGuardrail unchanged

AtmosphereAiAutoConfiguration bridges Spring-managed beans onto framework properties:

  • Every @Component / @Bean of type AiGuardrail → AiGuardrail.GUARDRAILS_PROPERTY
  • Every @Component / @Bean of type GovernancePolicy → GovernancePolicy.POLICIES_PROPERTY

Direct YAML loading (typical pattern):

@Configuration
public class PoliciesConfig {
@Bean
Object atmospherePolicyPlaneLoader(AtmosphereFramework framework) throws IOException {
try (var in = new ClassPathResource("atmosphere-policies.yaml").getInputStream()) {
var policies = new YamlPolicyParser()
.parse("classpath:atmosphere-policies.yaml", in);
framework.getAtmosphereConfig().properties()
.put(GovernancePolicy.POLICIES_PROPERTY, policies);
return policies;
}
}
}

Exposed by AtmosphereAdminEndpoint when atmosphere-admin is on the classpath. Wire-compatible with Microsoft Agent Governance Toolkit’s PolicyProviderHandler ASGI app.

Lists the live policy chain.

[
{ "name": "string", "source": "string",
"version": "string", "className": "string" }
]
{ "policyCount": 0, "sources": ["string"] }

GET /api/admin/governance/decisions?limit=N

Section titled “GET /api/admin/governance/decisions?limit=N”

Ring-buffered recent AuditEntry records (newest first).

[
{
"timestamp": "2026-04-21T22:04:08.802Z",
"policy_name": "scope::SupportChat",
"policy_source": "annotation:org.example.SupportChat",
"policy_version": "1.0",
"decision": "deny",
"reason": "message matched built-in hijacking probe: 'write python code'",
"evaluation_ms": 0.42,
"context_snapshot": { "phase": "pre_admission", "message": "write python code to sort an array" }
}
]

OWASP Agentic AI Top 10 self-assessment — full matrix with coverage + evidence pointers per row. Pairs with external agt verify-style compliance tooling.

MS /check-compatible decision endpoint.

Request:

{ "agent_id": "string", "action": "string", "context": { "<key>": "<value>" } }

Response:

{
"allowed": true,
"decision": "allow | deny | transform",
"reason": "string",
"matched_policy": "string | null",
"matched_source": "string | null",
"evaluation_ms": 0.0
}

Maps agent_id → AiRequest.agentId, each context entry onto AiRequest.metadata(). External gateways pointed at MS’s ASGI app work against Atmosphere without payload translation.

Operator snapshot aggregating kill-switch state, dry-run counters, SLO status, and per-policy hash fingerprints. Admin dashboards use this as a single-fetch status endpoint.

Compliance export shaped for Microsoft’s agt verify CLI — cross-framework findings (OWASP Agentic Top 10 + EU AI Act + HIPAA + SOC2) with per-row evidence pointers and a per-framework coverage summary. Round-trips into tooling that already consumes MS’s Agent Compliance package format.

Hot-reload a policy wrapped in SwappablePolicy. Body: {swapName, yaml}; response reports outgoing + incoming delegate identity.

POST /api/admin/governance/kill-switch/{arm,disarm}

Section titled “POST /api/admin/governance/kill-switch/{arm,disarm}”

Operator break-glass. Armed state halts every admission decision in sub-millisecond time. Live verification on the startup-team sample shows the same prompt that admits at 0.11ms deny at 0.09ms while armed — no redeploy, no restart.

Terminal window
curl -X POST http://localhost:8080/api/admin/governance/kill-switch/arm \
-H 'Content-Type: application/json' \
-d '{"reason":"incident-42","operator":"oncall"}'

Single-endpoint scope is half the story — cross-agent dispatch needs the same enforcement. Atmosphere’s FleetInterceptor SPI (in atmosphere-coordinator) gates every outbound AgentCall before it leaves the coordinator.

public interface FleetInterceptor {
Decision before(AgentCall call);
sealed interface Decision {
record Proceed() implements Decision {}
record Rewrite(AgentCall modifiedCall) implements Decision {}
record Deny(String reason) implements Decision {}
}
}

Install via AgentFleet.withInterceptor(interceptor). Denies synthesize a failed AgentResult without consuming the transport hop.

Ready-made bridge that runs the full GovernancePolicy chain on every dispatch. A coordinator mistakenly dispatching “write Python” to its research agent gets denied at the fleet boundary, not just at the user-facing entry.

var governed = fleet.withInterceptor(new GovernanceFleetInterceptor(policies));
var research = governed.agent("research").call("web_search", args);

Commitment records on cross-agent dispatch

Section titled “Commitment records on cross-agent dispatch”

When JournalingAgentFleet.signer() is installed and CommitmentRecordsFlag.isEnabled() is true (default off per v4 Phase B1), every dispatch emits a W3C Verifiable-Credential-subtype record signed with Ed25519. The admin Commitments tab renders verified records with a ✓ badge. Unique pairing with durable sessions: the signed audit trail survives pause-and-resume across the CheckpointStore — demonstrated in the checkpoint-agent sample.

Enable for a sample deployment:

@Bean CommitmentSigner signer() { return Ed25519CommitmentSigner.generate(); }
@PostConstruct void enable() { CommitmentRecordsFlag.override(Boolean.TRUE); }

Governance decisions normally flow one way — into the audit log the agent never sees. The learning-signal loop feeds them back into the model’s context, with no retraining (the idea from Jason Stanley’s Governance as a Learning Signal).

Produce — PolicyDecision.Prefer + the preference type. A soft-preference policy admits the turn but records that a preferred path exists. Author it in YAML:

policies:
- name: least-privilege-advisor
type: preference
config:
phrases: ["standing admin", "full access"]
prefer: "request a scoped, time-boxed credential for the single function"
reason: "standing admin grants violate least-privilege for this ticket type"

Carry — GovernanceFeedbackInterceptor. An AiInterceptor that re-injects recent deny / prefer decisions for the current subject (scoped conversation_id → session_id → user_id) into the next request’s system prompt:

@AiEndpoint(path = "/chat", interceptors = GovernanceFeedbackInterceptor.class)

It reads GovernanceDecisionLog (installed out-of-box by atmosphere-admin), is bounded and deduplicated, and fails open — a feedback error never breaks the turn.

Observe — the feedback frames. The injection is otherwise invisible to the client, so a turn that carried guidance sends two metadata frames just before its complete frame: ai.governance.feedback.injected (how many lines were injected, at most maxItems) and ai.governance.feedback.lines (those lines, each capped at 512 characters). Only lines the final system prompt still carries are reported, and nothing is sent when nothing was injected or on the error path. On an @AiEndpoint(broadcastReply = true) room the guidance is still injected but the frames are not sent, so one subscriber’s governance history never reaches the others. Like every AiInterceptor, it runs on the @AiEndpoint path only, not on AiPipeline. The Console shows the frames as the turn’s Governance guidance applied panel; see the AI reference. These frames are not in a release yet: they were added after 4.0.71.

Timing. On the @AiEndpoint streaming path the guardrail loop (which records the Prefer) runs before the interceptor (which injects), so a Prefer steers the same turn that triggered it. A hard Deny terminates its turn, so a denial is surfaced on the next turn from the decision-log ring buffer.

Contain — durable recall (opt-in). By default the source is the in-memory ring buffer, so lessons never touch long-term memory. Opt in to persist them across restarts:

atmosphere.ai.governance.memory.enabled = true
atmosphere.ai.governance.memory.ttl-seconds = 0 # 0 = no expiry
atmosphere.ai.governance.memory.min-confidence = 0.0 # read-gate floor

GovernanceMemorySink persists each deny/prefer as a provenance-tagged fact under a per-user namespace; GovernanceProvenanceMemory drops expired / below-confidence lessons on read, so a wrong lesson cannot compound. The primitives are runtime-agnostic (Spring / Quarkus / bare-JVM wire identically via GovernanceMemoryInstaller).

The spring-boot-ai-chat sample demonstrates the loop end-to-end on a real LLM.


SampleGoal 1 MS YAMLGoal 2 ScopeGoal 3 CommitmentsGoal 4 OWASP
spring-boot-ms-governance-chat✅✅—✅
spring-boot-ai-classroom✅✅——
spring-boot-multi-agent-startup-team✅✅✅✅
spring-boot-checkpoint-agent——✅—
spring-boot-mcp-server—✅—✅

Each sample’s e2e test boots the real Spring Boot context and asserts admission decisions at runtime — no mocking at the governance seam.


Every GovernancePolicy.evaluate decision emits:

  1. AuditEntry — structured record (policy identity, decision, reason, context snapshot, evaluation_ms) ring-buffered by GovernanceDecisionLog (default 500 entries). Surfaced via GET /api/admin/governance/decisions?limit=N.
  2. OpenTelemetry span — governance.policy.evaluate with attributes policy.name, policy.source, policy.version, policy.phase, policy.decision, policy.reason. Denied / errored spans carry status ERROR for Jaeger / Tempo visibility. Reflective classpath detection keeps OTel an optional dependency.
  3. Server log — structured Request denied by policy <name> (source=<uri>, version=<v>): <reason>.

The context snapshot is redaction-safe: message truncated to 200 chars, metadata values coerced to primitives or toString(). Long-term retention is operator responsibility — wire to Kafka / Postgres / etc. by reading GovernanceDecisionLog.installed().recent(N) on a schedule.

OwaspAgenticMatrix.MATRIX is a CI-pinned self-assessment (see tutorial 32 for the full reading and rationale). OwaspMatrixPinTest fails the build if any referenced Evidence.evidenceClass or Evidence.testClass no longer exists. Served over HTTP at GET /api/admin/governance/owasp.

Current tally: 6 COVERED, 2 PARTIAL, 1 DESIGN, 1 NOT_ADDRESSED. Honest reporting is the point — silent rounding defeats the self-assessment.

InvariantHow honored
#2 Terminal-path completenessPolicy exceptions fail-closed to Deny at every admission seam
#5 Runtime truthGovernanceController reports installed policies, not classpath or YAML intent
#7 Mode parityPolicyPlaneSourceParityTest — same admission decision whether policies came from YAML, code, or ServiceLoader