|
| 1 | +--- |
| 2 | +title: "Governance Policy Plane" |
| 3 | +description: "GovernancePolicy SPI, YAML schemas (native + Microsoft Agent Governance Toolkit), PolicyAdmissionGate, /api/admin/governance/* HTTP surface" |
| 4 | +--- |
| 5 | + |
| 6 | +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. |
| 7 | + |
| 8 | +Tutorial: [Governance Policy Plane](/docs/tutorial/30-governance-policy-plane/). Module: `atmosphere-ai`. Admin surface: `atmosphere-admin`. |
| 9 | + |
| 10 | +--- |
| 11 | + |
| 12 | +## SPIs |
| 13 | + |
| 14 | +### `GovernancePolicy` |
| 15 | + |
| 16 | +Package: `org.atmosphere.ai.governance`. Core declarative SPI — a named, versioned, source-identified `evaluate(PolicyContext) → PolicyDecision` function. |
| 17 | + |
| 18 | +```java |
| 19 | +public interface GovernancePolicy { |
| 20 | + String POLICIES_PROPERTY = "org.atmosphere.ai.governance.policies"; |
| 21 | + |
| 22 | + String name(); // stable identifier for audit trail |
| 23 | + String source(); // yaml:/path/to/file.yaml | classpath:file.yaml | code:<fqn> |
| 24 | + String version(); // semver, ISO date, or content hash — operator choice |
| 25 | + PolicyDecision evaluate(PolicyContext context); |
| 26 | +} |
| 27 | +``` |
| 28 | + |
| 29 | +Implementations MUST be thread-safe, side-effect-free (except metrics / logging), and MUST NOT throw — exceptions fail-closed to `Deny` at every admission seam. |
| 30 | + |
| 31 | +### `PolicyContext` |
| 32 | + |
| 33 | +```java |
| 34 | +public record PolicyContext(Phase phase, AiRequest request, String accumulatedResponse) { |
| 35 | + public enum Phase { PRE_ADMISSION, POST_RESPONSE } |
| 36 | + public static PolicyContext preAdmission(AiRequest request); |
| 37 | + public static PolicyContext postResponse(AiRequest request, String accumulatedResponse); |
| 38 | +} |
| 39 | +``` |
| 40 | + |
| 41 | +### `PolicyDecision` |
| 42 | + |
| 43 | +Sealed type: |
| 44 | + |
| 45 | +```java |
| 46 | +sealed interface PolicyDecision { |
| 47 | + record Admit() implements PolicyDecision { } |
| 48 | + record Transform(AiRequest modifiedRequest) implements PolicyDecision { } |
| 49 | + record Deny(String reason) implements PolicyDecision { } |
| 50 | + static PolicyDecision admit(); |
| 51 | + static PolicyDecision transform(AiRequest r); |
| 52 | + static PolicyDecision deny(String reason); |
| 53 | +} |
| 54 | +``` |
| 55 | + |
| 56 | +`Transform` on the post-response path is non-operational (streamed text is not retroactively rewritable) — the pipeline logs a warning and downgrades to `Admit`. |
| 57 | + |
| 58 | +### `PolicyParser` |
| 59 | + |
| 60 | +Pluggable YAML / Rego / Cedar parser. Discovered via `java.util.ServiceLoader`. |
| 61 | + |
| 62 | +```java |
| 63 | +public interface PolicyParser { |
| 64 | + String format(); // "yaml" | "rego" | "cedar" |
| 65 | + List<GovernancePolicy> parse(String source, InputStream in) throws IOException; |
| 66 | +} |
| 67 | +``` |
| 68 | + |
| 69 | +**One implementation ships in-tree:** `YamlPolicyParser` (SnakeYAML `SafeConstructor`, no arbitrary class instantiation). Auto-detects Atmosphere-native vs MS schema by root-key inspection. |
| 70 | + |
| 71 | +### `PolicyRegistry` |
| 72 | + |
| 73 | +Maps YAML `type:` names to factory functions. |
| 74 | + |
| 75 | +```java |
| 76 | +var registry = new PolicyRegistry(); // built-ins pre-registered |
| 77 | +registry.register("my-domain-policy", descriptor -> |
| 78 | + new MyDomainPolicy(descriptor.name(), descriptor.source(), |
| 79 | + descriptor.version(), descriptor.config())); |
| 80 | +var parser = new YamlPolicyParser(registry); |
| 81 | +``` |
| 82 | + |
| 83 | +Built-in types: |
| 84 | + |
| 85 | +| `type:` | Wraps | Config keys | |
| 86 | +|---|---|---| |
| 87 | +| `pii-redaction` | `PiiRedactionGuardrail` | `mode: redact \| block` | |
| 88 | +| `cost-ceiling` | `CostCeilingGuardrail` | `budget-usd: <number>` | |
| 89 | +| `output-length-zscore` | `OutputLengthZScoreGuardrail` | `window-size`, `z-threshold`, `min-samples` | |
| 90 | + |
| 91 | +### `PolicyAdmissionGate` |
| 92 | + |
| 93 | +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. |
| 94 | + |
| 95 | +```java |
| 96 | +var result = PolicyAdmissionGate.admit(resource, new AiRequest(message)); |
| 97 | +switch (result) { |
| 98 | + case PolicyAdmissionGate.Result.Denied denied -> /* session.error(...) */; |
| 99 | + case PolicyAdmissionGate.Result.Admitted admitted -> /* use admitted.request() */; |
| 100 | +} |
| 101 | +``` |
| 102 | + |
| 103 | +Fail-closed — a throwing policy becomes `Denied` with the exception message. |
| 104 | + |
| 105 | +### Adapters |
| 106 | + |
| 107 | +- `GuardrailAsPolicy(AiGuardrail)` — expose any `AiGuardrail` as a `GovernancePolicy`. |
| 108 | +- `PolicyAsGuardrail(GovernancePolicy)` — expose any `GovernancePolicy` as an `AiGuardrail`. Used internally by `AiEndpointProcessor` to merge policies into the guardrail list consumed by `AiPipeline`. |
| 109 | + |
| 110 | +--- |
| 111 | + |
| 112 | +## YAML schemas |
| 113 | + |
| 114 | +### Atmosphere-native (type-dispatch) |
| 115 | + |
| 116 | +```yaml |
| 117 | +version: "1.0" |
| 118 | +policies: |
| 119 | + - name: <unique-id> |
| 120 | + type: pii-redaction | cost-ceiling | output-length-zscore | <custom> |
| 121 | + version: "1.0" |
| 122 | + config: { ... } |
| 123 | +``` |
| 124 | +
|
| 125 | +The document `version:` is the fallback used when a policy entry omits its own `version:`. |
| 126 | + |
| 127 | +### Microsoft Agent Governance Toolkit (rules-over-context) |
| 128 | + |
| 129 | +Faithful port of MS's `_match_condition` + `PolicyEvaluator.evaluate` semantics. Documents with a top-level `rules:` sequence trigger the MS branch. |
| 130 | + |
| 131 | +```yaml |
| 132 | +version: "1.0" |
| 133 | +name: <policy-document-name> |
| 134 | +description: <optional> |
| 135 | +rules: |
| 136 | + - name: <rule-id> |
| 137 | + condition: |
| 138 | + field: <key> |
| 139 | + operator: eq | ne | gt | lt | gte | lte | in | contains | matches |
| 140 | + value: <scalar | list | regex> |
| 141 | + action: allow | deny | audit | block |
| 142 | + priority: <integer> # higher wins; pre-sorted descending at load |
| 143 | + message: <surfaced on Deny> |
| 144 | +defaults: |
| 145 | + action: allow | deny | audit | block |
| 146 | +``` |
| 147 | + |
| 148 | +**Operator semantics:** |
| 149 | + |
| 150 | +| Operator | Behavior | |
| 151 | +|---|---| |
| 152 | +| `eq` / `ne` | Loose equality (numeric cross-type aware — `1 == 1.0`) | |
| 153 | +| `gt`, `lt`, `gte`, `lte` | `Comparable`-based ordering | |
| 154 | +| `in` | Value appears in target list (target must be a sequence) | |
| 155 | +| `contains` | Substring (string context) or membership (collection context) | |
| 156 | +| `matches` | Regex via `java.util.regex.Pattern.matcher().find()` — compiled at parse time | |
| 157 | + |
| 158 | +**Action mapping:** |
| 159 | + |
| 160 | +| YAML | `PolicyDecision` | |
| 161 | +|---|---| |
| 162 | +| `allow` | `Admit` | |
| 163 | +| `deny` / `block` | `Deny(message)` | |
| 164 | +| `audit` | `Admit` + structured INFO log | |
| 165 | + |
| 166 | +**Context map** — rule `field:` references resolve against: |
| 167 | + |
| 168 | +| Key | Source | |
| 169 | +|---|---| |
| 170 | +| `message`, `system_prompt`, `model` | `AiRequest` direct fields | |
| 171 | +| `user_id`, `session_id`, `agent_id`, `conversation_id` | `AiRequest` direct fields | |
| 172 | +| `phase` | `pre_admission` / `post_response` | |
| 173 | +| `response` | Accumulated response text (post-response only) | |
| 174 | +| *anything else* | `AiRequest.metadata()` entries by exact key | |
| 175 | + |
| 176 | +**Schema exclusivity** — documents that mix `rules:` and `policies:` raise `IOException` at parse time. Pick one. |
| 177 | + |
| 178 | +**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. |
| 179 | + |
| 180 | +--- |
| 181 | + |
| 182 | +## AiPipeline wiring |
| 183 | + |
| 184 | +The canonical `AiPipeline` constructor accepts both guardrails and policies: |
| 185 | + |
| 186 | +```java |
| 187 | +new AiPipeline(runtime, systemPrompt, model, |
| 188 | + memory, toolRegistry, |
| 189 | + guardrails, policies, contextProviders, |
| 190 | + metrics, responseType); |
| 191 | +``` |
| 192 | + |
| 193 | +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. |
| 194 | + |
| 195 | +`AiEndpointProcessor` merges policies from three sources with dedup by `name()`: |
| 196 | + |
| 197 | +1. `ServiceLoader<GovernancePolicy>` (for framework-less / Quarkus deployments) |
| 198 | +2. Framework-property bag (`POLICIES_PROPERTY` — populated by YAML loaders or Spring auto-config) |
| 199 | +3. Annotation-declared classes on `@AiEndpoint(guardrails = {...})` continue to work via `AiGuardrail` unchanged |
| 200 | + |
| 201 | +--- |
| 202 | + |
| 203 | +## Spring Boot auto-configuration |
| 204 | + |
| 205 | +`AtmosphereAiAutoConfiguration` bridges Spring-managed beans onto framework properties: |
| 206 | + |
| 207 | +- Every `@Component` / `@Bean` of type `AiGuardrail` → `AiGuardrail.GUARDRAILS_PROPERTY` |
| 208 | +- Every `@Component` / `@Bean` of type `GovernancePolicy` → `GovernancePolicy.POLICIES_PROPERTY` |
| 209 | + |
| 210 | +Direct YAML loading (typical pattern): |
| 211 | + |
| 212 | +```java |
| 213 | +@Configuration |
| 214 | +public class PoliciesConfig { |
| 215 | + @Bean |
| 216 | + Object atmospherePolicyPlaneLoader(AtmosphereFramework framework) throws IOException { |
| 217 | + try (var in = new ClassPathResource("atmosphere-policies.yaml").getInputStream()) { |
| 218 | + var policies = new YamlPolicyParser() |
| 219 | + .parse("classpath:atmosphere-policies.yaml", in); |
| 220 | + framework.getAtmosphereConfig().properties() |
| 221 | + .put(GovernancePolicy.POLICIES_PROPERTY, policies); |
| 222 | + return policies; |
| 223 | + } |
| 224 | + } |
| 225 | +} |
| 226 | +``` |
| 227 | + |
| 228 | +--- |
| 229 | + |
| 230 | +## HTTP surface |
| 231 | + |
| 232 | +Exposed by `AtmosphereAdminEndpoint` when `atmosphere-admin` is on the classpath. Wire-compatible with Microsoft Agent Governance Toolkit's `PolicyProviderHandler` ASGI app. |
| 233 | + |
| 234 | +### `GET /api/admin/governance/policies` |
| 235 | + |
| 236 | +Lists the live policy chain. |
| 237 | + |
| 238 | +```json |
| 239 | +[ |
| 240 | + { "name": "string", "source": "string", |
| 241 | + "version": "string", "className": "string" } |
| 242 | +] |
| 243 | +``` |
| 244 | + |
| 245 | +### `GET /api/admin/governance/summary` |
| 246 | + |
| 247 | +```json |
| 248 | +{ "policyCount": 0, "sources": ["string"] } |
| 249 | +``` |
| 250 | + |
| 251 | +### `POST /api/admin/governance/check` |
| 252 | + |
| 253 | +MS `/check`-compatible decision endpoint. |
| 254 | + |
| 255 | +Request: |
| 256 | + |
| 257 | +```json |
| 258 | +{ "agent_id": "string", "action": "string", "context": { "<key>": "<value>" } } |
| 259 | +``` |
| 260 | + |
| 261 | +Response: |
| 262 | + |
| 263 | +```json |
| 264 | +{ |
| 265 | + "allowed": true, |
| 266 | + "decision": "allow | deny | transform", |
| 267 | + "reason": "string", |
| 268 | + "matched_policy": "string | null", |
| 269 | + "matched_source": "string | null", |
| 270 | + "evaluation_ms": 0.0 |
| 271 | +} |
| 272 | +``` |
| 273 | + |
| 274 | +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. |
| 275 | + |
| 276 | +--- |
| 277 | + |
| 278 | +## Correctness invariants |
| 279 | + |
| 280 | +| Invariant | How honored | |
| 281 | +|---|---| |
| 282 | +| **#2 Terminal-path completeness** | Policy exceptions fail-closed to `Deny` at every admission seam | |
| 283 | +| **#5 Runtime truth** | `GovernanceController` reports installed policies, not classpath or YAML intent | |
| 284 | +| **#7 Mode parity** | `PolicyPlaneSourceParityTest` — same admission decision whether policies came from YAML, code, or ServiceLoader | |
| 285 | + |
| 286 | +--- |
| 287 | + |
| 288 | +## Related |
| 289 | + |
| 290 | +- Tutorial: [Governance Policy Plane](/docs/tutorial/30-governance-policy-plane/) |
| 291 | +- Sample: [`samples/spring-boot-ms-governance-chat`](https://github.com/Atmosphere/atmosphere/tree/main/samples/spring-boot-ms-governance-chat) |
| 292 | +- In-tree detailed docs: [`docs/governance-policy-plane.md`](https://github.com/Atmosphere/atmosphere/blob/main/docs/governance-policy-plane.md) |
| 293 | +- Module reference: [`modules/ai/README.md`](https://github.com/Atmosphere/atmosphere/blob/main/modules/ai/README.md#governance-policy-plane-phase-a) |
| 294 | +- Upstream toolkit: [github.com/microsoft/agent-governance-toolkit](https://github.com/microsoft/agent-governance-toolkit) |
0 commit comments