policy-as-code-engine 0.1.1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,50 @@
1
+ """
2
+ policy-as-code-engine — declarative policy evaluation for Python services.
3
+
4
+ This package answers one question: *given this context object and these rules,
5
+ should the request be allowed?* It is deliberately small. Rules are JSON/YAML
6
+ documents; the engine walks them and produces a structured decision (allow /
7
+ deny / not-applicable) with the matching rule and the reason.
8
+
9
+ Designed to compose with the rest of the Kinetic Gain ecosystem:
10
+
11
+ procurement-decision-api -> drafts a Decision Card with conditions[]
12
+ policy-as-code-engine -> converts those conditions into a PolicyBundle,
13
+ then evaluates them against live requests
14
+
15
+ Two surfaces:
16
+
17
+ Library: from policy_as_code_engine import PolicyEvaluator
18
+ HTTP: uvicorn policy_as_code_engine.app:app (optional `[api]` extra)
19
+ """
20
+
21
+ from __future__ import annotations
22
+
23
+ from .evaluator import PolicyEvaluator
24
+ from .from_decision_card import policy_bundle_from_decision_card
25
+ from .models import (
26
+ Decision,
27
+ DecisionKind,
28
+ EvaluationContext,
29
+ EvaluationResult,
30
+ Matcher,
31
+ Policy,
32
+ PolicyBundle,
33
+ Rule,
34
+ )
35
+
36
+ __version__ = "0.1.1"
37
+
38
+ __all__ = [
39
+ "Decision",
40
+ "DecisionKind",
41
+ "EvaluationContext",
42
+ "EvaluationResult",
43
+ "Matcher",
44
+ "Policy",
45
+ "PolicyBundle",
46
+ "PolicyEvaluator",
47
+ "Rule",
48
+ "__version__",
49
+ "policy_bundle_from_decision_card",
50
+ ]
@@ -0,0 +1,53 @@
1
+ """
2
+ Entry point. Either run the HTTP API or evaluate a bundle from the CLI.
3
+
4
+ # API
5
+ python -m policy_as_code_engine # binds 0.0.0.0:8089
6
+
7
+ # CLI eval
8
+ python -m policy_as_code_engine eval bundle.yaml ctx.json
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import json
14
+ import os
15
+ import sys
16
+ from pathlib import Path
17
+
18
+ from .evaluator import PolicyEvaluator
19
+ from .loader import load_bundle_from_file
20
+ from .models import EvaluationContext
21
+
22
+
23
+ def _serve() -> None:
24
+ import uvicorn
25
+
26
+ port = int(os.environ.get("PORT", "8089"))
27
+ host = os.environ.get("HOST", "0.0.0.0")
28
+ uvicorn.run("policy_as_code_engine.app:app", host=host, port=port, log_level="info")
29
+
30
+
31
+ def _eval(bundle_path: str, context_path: str) -> int:
32
+ bundle = load_bundle_from_file(bundle_path)
33
+ raw_ctx = Path(context_path).read_text(encoding="utf-8")
34
+ context = EvaluationContext.model_validate_json(raw_ctx)
35
+ result = PolicyEvaluator().evaluate(bundle, context)
36
+ print(json.dumps(result.model_dump(mode="json"), indent=2))
37
+ return 0 if result.decision.kind != "deny" else 1
38
+
39
+
40
+ def main() -> None:
41
+ args = sys.argv[1:]
42
+ if not args:
43
+ _serve()
44
+ return
45
+ if args[0] == "eval" and len(args) == 3:
46
+ sys.exit(_eval(args[1], args[2]))
47
+ print("usage: python -m policy_as_code_engine # run HTTP API")
48
+ print(" python -m policy_as_code_engine eval BUNDLE CTX # CLI eval")
49
+ sys.exit(2)
50
+
51
+
52
+ if __name__ == "__main__":
53
+ main()
@@ -0,0 +1,226 @@
1
+ """
2
+ FastAPI app — five endpoints.
3
+
4
+ GET / service info
5
+ GET /healthz liveness probe
6
+ POST /bundles register a PolicyBundle (in-memory)
7
+ GET /bundles/{bundle_id} inspect a registered bundle
8
+ POST /bundles/{bundle_id}/evaluate evaluate it against a context
9
+ POST /evaluate one-shot: bundle + context in, decision out
10
+ POST /bundles/from-decision-card build a bundle from a Decision Card
11
+
12
+ Bundles are held in process memory by default; restart-safe storage is a
13
+ caller responsibility. Wire a Redis / Postgres backend by replacing the
14
+ `_BundleStore` instance in `lifespan`.
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ from collections.abc import AsyncIterator
20
+ from contextlib import asynccontextmanager
21
+ from threading import Lock
22
+ from typing import Any
23
+
24
+ import httpx
25
+ from fastapi import FastAPI, HTTPException, status
26
+ from pydantic import BaseModel, ValidationError
27
+
28
+ from . import __version__, audit_stream
29
+ from .evaluator import PolicyEvaluator
30
+ from .from_decision_card import policy_bundle_from_decision_card
31
+ from .models import EvaluationContext, EvaluationResult, PolicyBundle
32
+
33
+
34
+ class _BundleStore:
35
+ """Thread-safe in-memory bundle store."""
36
+
37
+ __slots__ = ("_bundles", "_lock")
38
+
39
+ def __init__(self) -> None:
40
+ self._bundles: dict[str, PolicyBundle] = {}
41
+ self._lock = Lock()
42
+
43
+ def put(self, bundle: PolicyBundle) -> None:
44
+ with self._lock:
45
+ self._bundles[bundle.bundle_id] = bundle
46
+
47
+ def get(self, bundle_id: str) -> PolicyBundle:
48
+ with self._lock:
49
+ try:
50
+ return self._bundles[bundle_id]
51
+ except KeyError as err:
52
+ raise KeyError(bundle_id) from err
53
+
54
+ def list_ids(self) -> list[str]:
55
+ with self._lock:
56
+ return list(self._bundles.keys())
57
+
58
+
59
+ class _OneShotRequest(BaseModel):
60
+ bundle: PolicyBundle
61
+ context: EvaluationContext
62
+
63
+
64
+ @asynccontextmanager
65
+ async def _lifespan(app: FastAPI) -> AsyncIterator[None]:
66
+ app.state.store = _BundleStore()
67
+ app.state.evaluator = PolicyEvaluator()
68
+ # Shared httpx client for best-effort audit-stream emission. Always
69
+ # created; the audit_stream module no-ops when AUDIT_STREAM_URL is unset.
70
+ app.state.http_client = httpx.AsyncClient(
71
+ headers={"User-Agent": f"policy-as-code-engine/{__version__} (+https://kineticgain.com)"},
72
+ )
73
+ try:
74
+ yield
75
+ finally:
76
+ await app.state.http_client.aclose()
77
+
78
+
79
+ app = FastAPI(
80
+ title="policy-as-code-engine",
81
+ version=__version__,
82
+ description=(
83
+ "Declarative policy-as-code evaluator. Pairs with procurement-decision-api: "
84
+ "drafted Decision Cards become enforceable PolicyBundles via "
85
+ "POST /bundles/from-decision-card."
86
+ ),
87
+ lifespan=_lifespan,
88
+ )
89
+
90
+
91
+ def _store() -> _BundleStore:
92
+ """Typed accessor for app.state.store — keeps mypy strict happy."""
93
+ store = app.state.store
94
+ assert isinstance(store, _BundleStore)
95
+ return store
96
+
97
+
98
+ def _evaluator() -> PolicyEvaluator:
99
+ evaluator = app.state.evaluator
100
+ assert isinstance(evaluator, PolicyEvaluator)
101
+ return evaluator
102
+
103
+
104
+ def _http_client() -> httpx.AsyncClient:
105
+ """Shared httpx client used by audit_stream.emit (best-effort)."""
106
+ client = app.state.http_client
107
+ assert isinstance(client, httpx.AsyncClient)
108
+ return client
109
+
110
+
111
+ @app.get("/", tags=["meta"])
112
+ async def root() -> dict[str, Any]:
113
+ return {
114
+ "name": "policy-as-code-engine",
115
+ "version": __version__,
116
+ "description": (
117
+ "Evaluates declarative policy bundles against arbitrary request contexts. "
118
+ "Bridges to the Kinetic Gain Protocol Suite via /bundles/from-decision-card."
119
+ ),
120
+ "endpoints": {
121
+ "GET /": "this page",
122
+ "GET /healthz": "liveness probe",
123
+ "GET /bundles": "list registered bundle IDs",
124
+ "POST /bundles": "register a PolicyBundle",
125
+ "GET /bundles/{bundle_id}": "fetch a registered bundle",
126
+ "POST /bundles/{bundle_id}/evaluate": "evaluate a stored bundle against a context",
127
+ "POST /evaluate": "one-shot: bundle + context in, decision out",
128
+ "POST /bundles/from-decision-card": "build a bundle from a Decision Card",
129
+ },
130
+ }
131
+
132
+
133
+ @app.get("/healthz", tags=["meta"])
134
+ async def healthz() -> dict[str, str]:
135
+ return {"status": "ok"}
136
+
137
+
138
+ @app.get("/bundles", tags=["bundles"])
139
+ async def list_bundles() -> dict[str, list[str]]:
140
+ return {"bundle_ids": _store().list_ids()}
141
+
142
+
143
+ @app.post("/bundles", tags=["bundles"], status_code=201)
144
+ async def register_bundle(bundle: PolicyBundle) -> dict[str, str]:
145
+ _store().put(bundle)
146
+ await audit_stream.emit(
147
+ _http_client(),
148
+ kind="policy_bundle_registered",
149
+ payload={
150
+ "bundle_id": bundle.bundle_id,
151
+ "policy_count": len(bundle.policies),
152
+ "source": bundle.source,
153
+ },
154
+ )
155
+ return {"bundle_id": bundle.bundle_id, "status": "registered"}
156
+
157
+
158
+ @app.get("/bundles/{bundle_id}", tags=["bundles"])
159
+ async def get_bundle(bundle_id: str) -> PolicyBundle:
160
+ try:
161
+ return _store().get(bundle_id)
162
+ except KeyError as err:
163
+ raise HTTPException(status_code=404, detail=f"unknown bundle: {bundle_id!r}") from err
164
+
165
+
166
+ async def _emit_decision(bundle_id: str, result: EvaluationResult) -> None:
167
+ """Emit request_allowed / request_denied; skip on not_applicable."""
168
+ kind_map = {"allow": "request_allowed", "deny": "request_denied"}
169
+ event_kind = kind_map.get(result.decision.kind)
170
+ if event_kind is None:
171
+ return
172
+ await audit_stream.emit(
173
+ _http_client(),
174
+ kind=event_kind,
175
+ payload={
176
+ "bundle_id": bundle_id,
177
+ "decision": result.decision.kind,
178
+ "matched_policy_id": result.decision.matched_policy_id,
179
+ "matched_rule_id": result.decision.matched_rule_id,
180
+ "reason": result.decision.reason,
181
+ },
182
+ )
183
+
184
+
185
+ @app.post("/bundles/{bundle_id}/evaluate", tags=["evaluate"])
186
+ async def evaluate_registered(bundle_id: str, context: EvaluationContext) -> EvaluationResult:
187
+ try:
188
+ bundle = _store().get(bundle_id)
189
+ except KeyError as err:
190
+ raise HTTPException(status_code=404, detail=f"unknown bundle: {bundle_id!r}") from err
191
+ result = _evaluator().evaluate(bundle, context)
192
+ await _emit_decision(bundle.bundle_id, result)
193
+ return result
194
+
195
+
196
+ @app.post("/evaluate", tags=["evaluate"])
197
+ async def evaluate_oneshot(request: _OneShotRequest) -> EvaluationResult:
198
+ result = _evaluator().evaluate(request.bundle, request.context)
199
+ await _emit_decision(request.bundle.bundle_id, result)
200
+ return result
201
+
202
+
203
+ @app.post("/bundles/from-decision-card", tags=["bridge"], status_code=201)
204
+ async def bundle_from_decision_card(card: dict[str, Any]) -> PolicyBundle:
205
+ """
206
+ Translate a Kinetic Gain Procurement Decision Card into a PolicyBundle
207
+ and register it. This is the cross-ecosystem hook.
208
+ """
209
+ try:
210
+ bundle = policy_bundle_from_decision_card(card)
211
+ except (ValueError, ValidationError) as err:
212
+ raise HTTPException(
213
+ status_code=status.HTTP_400_BAD_REQUEST,
214
+ detail=str(err),
215
+ ) from err
216
+ _store().put(bundle)
217
+ await audit_stream.emit(
218
+ _http_client(),
219
+ kind="policy_bundle_registered",
220
+ payload={
221
+ "bundle_id": bundle.bundle_id,
222
+ "policy_count": len(bundle.policies),
223
+ "source": bundle.source,
224
+ },
225
+ )
226
+ return bundle
@@ -0,0 +1,82 @@
1
+ """
2
+ Optional audit-stream-py integration.
3
+
4
+ When the `AUDIT_STREAM_URL` env var is set, this module fires governance
5
+ events at `{AUDIT_STREAM_URL}/events` for the moments the service produces.
6
+ Best-effort: a failed POST is logged, not raised — audit-stream outages
7
+ must never block policy registration or evaluation.
8
+
9
+ Event kinds this service emits:
10
+ policy_bundle_registered on POST /bundles (any successful registration,
11
+ including the from-decision-card bridge)
12
+ request_allowed on POST /bundles/{id}/evaluate when the bundle
13
+ decision is "allow"
14
+ request_denied on the same endpoint when the bundle decision
15
+ is "deny"
16
+
17
+ Same opt-in pattern as procurement-decision-api.audit_stream and
18
+ aeo-validator-service.audit_stream. Identical config envvars.
19
+ """
20
+
21
+ from __future__ import annotations
22
+
23
+ import os
24
+ from typing import Any
25
+
26
+ import httpx
27
+
28
+ DEFAULT_TIMEOUT_S = 2.5
29
+
30
+
31
+ def is_enabled() -> bool:
32
+ """True when AUDIT_STREAM_URL is set to a non-empty value."""
33
+ return bool(os.environ.get("AUDIT_STREAM_URL", "").strip())
34
+
35
+
36
+ def base_url() -> str | None:
37
+ """Stripped audit-stream base URL, or None when disabled."""
38
+ raw = os.environ.get("AUDIT_STREAM_URL", "").strip()
39
+ if not raw:
40
+ return None
41
+ return raw.rstrip("/")
42
+
43
+
44
+ def timeout_s() -> float:
45
+ """Configured per-call timeout. Defaults to 2.5s."""
46
+ raw = os.environ.get("AUDIT_STREAM_TIMEOUT_S", "").strip()
47
+ if not raw:
48
+ return DEFAULT_TIMEOUT_S
49
+ try:
50
+ return max(0.1, float(raw))
51
+ except ValueError:
52
+ return DEFAULT_TIMEOUT_S
53
+
54
+
55
+ async def emit(
56
+ client: httpx.AsyncClient,
57
+ *,
58
+ kind: str,
59
+ payload: dict[str, Any],
60
+ ) -> None:
61
+ """Fire one event. Silent no-op when AUDIT_STREAM_URL is unset."""
62
+ url = base_url()
63
+ if url is None:
64
+ return
65
+
66
+ body = {
67
+ "kind": kind,
68
+ "source": "policy-as-code-engine",
69
+ "payload": payload,
70
+ }
71
+ try:
72
+ response = await client.post(
73
+ f"{url}/events",
74
+ json=body,
75
+ timeout=timeout_s(),
76
+ )
77
+ response.raise_for_status()
78
+ except (httpx.HTTPError, OSError) as err:
79
+ print(
80
+ f"audit-stream emit failed (kind={kind}): {type(err).__name__}: {err}",
81
+ flush=True,
82
+ )
@@ -0,0 +1,197 @@
1
+ """
2
+ Policy evaluator. The hot path.
3
+
4
+ `PolicyEvaluator.evaluate(bundle, context)` walks every policy in the bundle,
5
+ finds the first rule whose `when` matches, and emits a `Decision` for that
6
+ policy. The bundle-level result then combines those per-policy decisions
7
+ (deny > allow > not_applicable).
8
+
9
+ The matcher logic lives in `_match()` and uses `EvaluationContext.lookup()`
10
+ for dotted-path traversal. Special-case: the `_MISSING` sentinel from
11
+ `models.lookup` is recognised so `exists` / `missing` matchers work correctly
12
+ without requiring the rule author to know about the sentinel.
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ import re
18
+ from typing import Any
19
+
20
+ from .models import (
21
+ _MISSING,
22
+ AllOfMatcher,
23
+ AlwaysMatcher,
24
+ AnyOfMatcher,
25
+ Decision,
26
+ EvaluationContext,
27
+ EvaluationResult,
28
+ FieldMatcher,
29
+ Matcher,
30
+ NotMatcher,
31
+ Policy,
32
+ PolicyBundle,
33
+ Rule,
34
+ )
35
+
36
+
37
+ class PolicyEvaluator:
38
+ """Stateless evaluator. Cheap to construct; reuse one per process."""
39
+
40
+ __slots__ = ("_regex_cache",)
41
+
42
+ def __init__(self) -> None:
43
+ # Compiled-regex cache so repeated evaluations don't pay the parse cost.
44
+ self._regex_cache: dict[str, re.Pattern[str]] = {}
45
+
46
+ def evaluate(self, bundle: PolicyBundle, context: EvaluationContext) -> EvaluationResult:
47
+ policy_decisions: list[Decision] = []
48
+ for policy in bundle.policies:
49
+ policy_decisions.append(self._evaluate_policy(policy, context))
50
+ return EvaluationResult.combine(bundle.bundle_id, policy_decisions)
51
+
52
+ def evaluate_policy(self, policy: Policy, context: EvaluationContext) -> Decision:
53
+ """Evaluate a single policy. Useful when callers manage bundles externally."""
54
+ return self._evaluate_policy(policy, context)
55
+
56
+ # ---- internals -----------------------------------------------------
57
+
58
+ def _evaluate_policy(self, policy: Policy, context: EvaluationContext) -> Decision:
59
+ for rule in policy.rules:
60
+ if self._match(rule.when, context):
61
+ return Decision(
62
+ kind=rule.effect,
63
+ matched_policy_id=policy.id,
64
+ matched_rule_id=rule.id,
65
+ reason=rule.description or f"matched rule {rule.id!r}",
66
+ )
67
+ # No rule matched — fall back to the policy default.
68
+ return Decision(
69
+ kind=policy.default_effect,
70
+ matched_policy_id=policy.id,
71
+ matched_rule_id=None,
72
+ reason=f"no rule matched; policy default_effect={policy.default_effect!r}",
73
+ )
74
+
75
+ def _match(self, matcher: Matcher, context: EvaluationContext) -> bool:
76
+ if isinstance(matcher, AlwaysMatcher):
77
+ return True
78
+ if isinstance(matcher, AllOfMatcher):
79
+ return all(self._match(m, context) for m in matcher.matchers)
80
+ if isinstance(matcher, AnyOfMatcher):
81
+ return any(self._match(m, context) for m in matcher.matchers)
82
+ if isinstance(matcher, NotMatcher):
83
+ return not self._match(matcher.matcher, context)
84
+ if isinstance(matcher, FieldMatcher):
85
+ return self._match_field(matcher, context)
86
+ # Exhaustive over the Matcher union — this is unreachable.
87
+ raise TypeError(f"unknown matcher type: {type(matcher).__name__}") # pragma: no cover
88
+
89
+ def _match_field(self, matcher: FieldMatcher, context: EvaluationContext) -> bool:
90
+ actual = context.lookup(matcher.field)
91
+ present = actual is not _MISSING
92
+ kind = matcher.kind
93
+
94
+ if kind == "exists":
95
+ return present
96
+ if kind == "missing":
97
+ return not present
98
+ if not present:
99
+ return False # any other matcher against a missing field is False
100
+
101
+ try:
102
+ return bool(_COMPARE[kind](self, actual, matcher.value))
103
+ except KeyError: # pragma: no cover - safety
104
+ raise TypeError(f"unsupported field matcher kind: {kind}") from None
105
+
106
+ # ---- comparison helpers --------------------------------------------
107
+
108
+ @staticmethod
109
+ def _eq(actual: Any, expected: Any) -> bool:
110
+ return bool(actual == expected)
111
+
112
+ @staticmethod
113
+ def _ne(actual: Any, expected: Any) -> bool:
114
+ return bool(actual != expected)
115
+
116
+ @staticmethod
117
+ def _gt(actual: Any, expected: Any) -> bool:
118
+ try:
119
+ return bool(actual > expected)
120
+ except TypeError:
121
+ return False
122
+
123
+ @staticmethod
124
+ def _gte(actual: Any, expected: Any) -> bool:
125
+ try:
126
+ return bool(actual >= expected)
127
+ except TypeError:
128
+ return False
129
+
130
+ @staticmethod
131
+ def _lt(actual: Any, expected: Any) -> bool:
132
+ try:
133
+ return bool(actual < expected)
134
+ except TypeError:
135
+ return False
136
+
137
+ @staticmethod
138
+ def _lte(actual: Any, expected: Any) -> bool:
139
+ try:
140
+ return bool(actual <= expected)
141
+ except TypeError:
142
+ return False
143
+
144
+ @staticmethod
145
+ def _in(actual: Any, expected: Any) -> bool:
146
+ return actual in expected
147
+
148
+ @staticmethod
149
+ def _not_in(actual: Any, expected: Any) -> bool:
150
+ return actual not in expected
151
+
152
+ @staticmethod
153
+ def _contains(actual: Any, expected: Any) -> bool:
154
+ if isinstance(actual, (list, tuple, set, str)):
155
+ return expected in actual
156
+ if isinstance(actual, dict):
157
+ return expected in actual
158
+ return False
159
+
160
+ def _regex(self, actual: Any, expected: Any) -> bool:
161
+ if not isinstance(actual, str) or not isinstance(expected, str):
162
+ return False
163
+ pattern = self._regex_cache.get(expected)
164
+ if pattern is None:
165
+ pattern = re.compile(expected)
166
+ self._regex_cache[expected] = pattern
167
+ return pattern.search(actual) is not None
168
+
169
+ @staticmethod
170
+ def _starts_with(actual: Any, expected: Any) -> bool:
171
+ return isinstance(actual, str) and isinstance(expected, str) and actual.startswith(expected)
172
+
173
+ @staticmethod
174
+ def _ends_with(actual: Any, expected: Any) -> bool:
175
+ return isinstance(actual, str) and isinstance(expected, str) and actual.endswith(expected)
176
+
177
+
178
+ # Dispatch table for FieldMatcher.kind. Kept module-level so `_match_field`
179
+ # stays a tight if/else-free hot path.
180
+ _COMPARE: dict[str, Any] = {
181
+ "eq": lambda self, a, b: PolicyEvaluator._eq(a, b),
182
+ "ne": lambda self, a, b: PolicyEvaluator._ne(a, b),
183
+ "gt": lambda self, a, b: PolicyEvaluator._gt(a, b),
184
+ "gte": lambda self, a, b: PolicyEvaluator._gte(a, b),
185
+ "lt": lambda self, a, b: PolicyEvaluator._lt(a, b),
186
+ "lte": lambda self, a, b: PolicyEvaluator._lte(a, b),
187
+ "in": lambda self, a, b: PolicyEvaluator._in(a, b),
188
+ "not_in": lambda self, a, b: PolicyEvaluator._not_in(a, b),
189
+ "contains": lambda self, a, b: PolicyEvaluator._contains(a, b),
190
+ "regex": lambda self, a, b: self._regex(a, b),
191
+ "starts_with": lambda self, a, b: PolicyEvaluator._starts_with(a, b),
192
+ "ends_with": lambda self, a, b: PolicyEvaluator._ends_with(a, b),
193
+ }
194
+
195
+
196
+ # Unused at runtime — re-export so tests can build `Rule(...)` directly.
197
+ __all__ = ["PolicyEvaluator", "Rule"]
@@ -0,0 +1,162 @@
1
+ """
2
+ Bridge: AI Procurement Decision Card -> PolicyBundle.
3
+
4
+ The decision card spec encodes a buyer's posture toward a vendor as a `decision`
5
+ plus zero or more `conditions[]`. This module converts that human-authored
6
+ artifact into something a service can enforce automatically:
7
+
8
+ decision.status = "rejected*" -> single deny-all policy
9
+ decision.status = "approved" -> single allow-all policy
10
+ decision.status = "approved-with-conditions" -> per-condition policy +
11
+ fail-safe deny-all default
12
+ decision.status = "withdrawn" / "expired" / etc. -> single deny-all policy
13
+
14
+ For each condition we emit one Policy whose default_effect is `deny` and whose
15
+ single rule allows when the condition is *known to be satisfied* (signal field
16
+ `conditions_satisfied.{condition_id} == true` on the EvaluationContext).
17
+ Callers wire their own satisfaction signal into the context — e.g. result of a
18
+ verification job, a recent attestation timestamp, an external compliance check.
19
+
20
+ This is the most important hook in the package: it's what closes the loop
21
+ between the Kinetic Gain Protocol Suite (spec #11) and runtime enforcement.
22
+ """
23
+
24
+ from __future__ import annotations
25
+
26
+ from typing import Any
27
+
28
+ from .models import (
29
+ AlwaysMatcher,
30
+ FieldMatcher,
31
+ Policy,
32
+ PolicyBundle,
33
+ Rule,
34
+ )
35
+
36
+ _REJECT_STATUSES = {"rejected", "rejected-with-remediation", "withdrawn", "expired"}
37
+ _APPROVE_STATUSES = {"approved"}
38
+ _CONDITIONAL_STATUSES = {"approved-with-conditions"}
39
+
40
+
41
+ def policy_bundle_from_decision_card(card: dict[str, Any]) -> PolicyBundle:
42
+ """
43
+ Build a PolicyBundle from a v0.1 Decision Card dict (matching the upstream
44
+ `decision-card.schema.json`).
45
+
46
+ The returned bundle is **runtime-evaluatable** — every policy's first
47
+ matching rule fires `allow` or `deny`. Conditions become policies whose
48
+ satisfaction is signalled by `conditions_satisfied.{id}` on the context.
49
+ """
50
+ _validate_minimal_shape(card)
51
+ decision_id = card["decision_id"]
52
+ status = card["decision"]["status"]
53
+ vendor = card["subject"]["vendor_name"]
54
+ conditions = card.get("conditions") or []
55
+ source = f"decision-card:{decision_id}"
56
+
57
+ if status in _REJECT_STATUSES:
58
+ return _bundle(decision_id, source, [_deny_all_policy(decision_id, status, vendor)])
59
+
60
+ if status in _APPROVE_STATUSES:
61
+ return _bundle(decision_id, source, [_allow_all_policy(decision_id, vendor)])
62
+
63
+ if status in _CONDITIONAL_STATUSES:
64
+ if not conditions:
65
+ # The card itself should have failed validation upstream, but be
66
+ # defensive: an approved-with-conditions card with no conditions
67
+ # is treated as deny-all to fail safe.
68
+ return _bundle(decision_id, source, [_deny_all_policy(decision_id, status, vendor)])
69
+ policies = [_condition_policy(decision_id, c) for c in conditions]
70
+ return _bundle(decision_id, source, policies)
71
+
72
+ # Any unknown / pending status: fail safe.
73
+ return _bundle(decision_id, source, [_deny_all_policy(decision_id, status, vendor)])
74
+
75
+
76
+ # ---------------------------------------------------------------------------
77
+ # Builders
78
+ # ---------------------------------------------------------------------------
79
+
80
+
81
+ def _bundle(decision_id: str, source: str, policies: list[Policy]) -> PolicyBundle:
82
+ return PolicyBundle(
83
+ bundle_id=f"decision-card-{decision_id}",
84
+ version="0.1.0",
85
+ description=f"Generated from Decision Card {decision_id!r}.",
86
+ source=source,
87
+ policies=policies,
88
+ )
89
+
90
+
91
+ def _allow_all_policy(decision_id: str, vendor: str) -> Policy:
92
+ return Policy(
93
+ id=f"{decision_id}__approved",
94
+ description=f"Vendor {vendor!r} is approved; all requests permitted.",
95
+ default_effect="allow",
96
+ rules=[
97
+ Rule(
98
+ id="approved-allow",
99
+ effect="allow",
100
+ when=AlwaysMatcher(),
101
+ description=f"Decision Card {decision_id!r} approved vendor {vendor!r}.",
102
+ tags=["approved"],
103
+ )
104
+ ],
105
+ )
106
+
107
+
108
+ def _deny_all_policy(decision_id: str, status: str, vendor: str) -> Policy:
109
+ return Policy(
110
+ id=f"{decision_id}__{status}",
111
+ description=f"Vendor {vendor!r} is {status}; all requests denied.",
112
+ default_effect="deny",
113
+ rules=[
114
+ Rule(
115
+ id=f"{status}-deny",
116
+ effect="deny",
117
+ when=AlwaysMatcher(),
118
+ description=f"Decision Card {decision_id!r} status={status!r} for vendor {vendor!r}.",
119
+ tags=[status],
120
+ )
121
+ ],
122
+ )
123
+
124
+
125
+ def _condition_policy(decision_id: str, condition: dict[str, Any]) -> Policy:
126
+ """
127
+ Translate a single condition into a single policy:
128
+ - rule 1: ALLOW when conditions_satisfied.{id} is true
129
+ - default: DENY (fail-safe)
130
+ """
131
+ cid = condition.get("id")
132
+ if not cid or not isinstance(cid, str):
133
+ raise ValueError("each condition must carry a non-empty `id`")
134
+ description = condition.get("description") or f"Condition {cid!r}"
135
+ return Policy(
136
+ id=f"{decision_id}__condition__{cid}",
137
+ description=description,
138
+ default_effect="deny",
139
+ rules=[
140
+ Rule(
141
+ id=f"{cid}-satisfied",
142
+ effect="allow",
143
+ when=FieldMatcher(
144
+ kind="eq",
145
+ field=f"conditions_satisfied.{cid}",
146
+ value=True,
147
+ ),
148
+ description=description,
149
+ tags=["condition", cid],
150
+ )
151
+ ],
152
+ )
153
+
154
+
155
+ def _validate_minimal_shape(card: dict[str, Any]) -> None:
156
+ for k in ("decision_id", "decision", "subject"):
157
+ if k not in card:
158
+ raise ValueError(f"Decision Card is missing required key {k!r}")
159
+ if "status" not in card["decision"]:
160
+ raise ValueError("Decision Card.decision is missing required key 'status'")
161
+ if "vendor_name" not in card["subject"]:
162
+ raise ValueError("Decision Card.subject is missing required key 'vendor_name'")
@@ -0,0 +1,46 @@
1
+ """
2
+ File loaders for `PolicyBundle`.
3
+
4
+ Supports JSON and YAML so operators can author bundles in whichever format
5
+ their tooling already speaks. The loader is intentionally thin — the heavy
6
+ lifting is in `models.PolicyBundle.model_validate`.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import json
12
+ from pathlib import Path
13
+ from typing import Any
14
+
15
+ import yaml
16
+
17
+ from .models import PolicyBundle
18
+
19
+
20
+ def load_bundle_from_string(raw: str, *, fmt: str | None = None) -> PolicyBundle:
21
+ """
22
+ Parse a serialized bundle. `fmt` may be `"json"` or `"yaml"`; if omitted
23
+ the loader sniffs the leading character (`{` / `[` -> JSON, else YAML).
24
+ """
25
+ fmt = (fmt or _sniff(raw)).lower()
26
+ if fmt == "json":
27
+ parsed: Any = json.loads(raw)
28
+ elif fmt in ("yaml", "yml"):
29
+ parsed = yaml.safe_load(raw)
30
+ else:
31
+ raise ValueError(f"unsupported bundle format: {fmt!r}")
32
+ return PolicyBundle.model_validate(parsed)
33
+
34
+
35
+ def load_bundle_from_file(path: str | Path) -> PolicyBundle:
36
+ p = Path(path)
37
+ raw = p.read_text(encoding="utf-8")
38
+ fmt = "yaml" if p.suffix.lower() in (".yaml", ".yml") else "json"
39
+ return load_bundle_from_string(raw, fmt=fmt)
40
+
41
+
42
+ def _sniff(raw: str) -> str:
43
+ stripped = raw.lstrip()
44
+ if stripped.startswith(("{", "[")):
45
+ return "json"
46
+ return "yaml"
@@ -0,0 +1,218 @@
1
+ """
2
+ Pydantic v2 models for policies, rules, matchers, and evaluation results.
3
+
4
+ A `PolicyBundle` is the top-level object — a named, versioned collection of
5
+ `Policy` objects. Each `Policy` is a list of `Rule` objects, evaluated in
6
+ declared order; the first match wins (deny rules trump allow rules at the
7
+ PolicyBundle level — see `EvaluationResult.combine`).
8
+
9
+ A `Rule` has:
10
+ - id stable identifier for telemetry
11
+ - effect "allow" | "deny"
12
+ - description optional human-readable note
13
+ - when a `Matcher` — the predicate over the EvaluationContext
14
+
15
+ A `Matcher` is one of the supported operators. Matchers are recursive: `all_of`,
16
+ `any_of`, and `not_` wrap child matchers, so the result is a small DSL that
17
+ covers ~95% of real-world request gates without having to ship a parser.
18
+ """
19
+
20
+ from __future__ import annotations
21
+
22
+ from typing import Any, Literal
23
+
24
+ from pydantic import BaseModel, ConfigDict, Field, model_validator
25
+
26
+
27
+ class StrictModel(BaseModel):
28
+ model_config = ConfigDict(extra="forbid")
29
+
30
+
31
+ DecisionKind = Literal["allow", "deny", "not_applicable"]
32
+ Effect = Literal["allow", "deny"]
33
+
34
+
35
+ # ---------------------------------------------------------------------------
36
+ # Matchers — the DSL
37
+ # ---------------------------------------------------------------------------
38
+
39
+
40
+ class FieldMatcher(StrictModel):
41
+ """Compare a JSON-pointer-ish dotted path against a literal value."""
42
+
43
+ kind: Literal[
44
+ "eq",
45
+ "ne",
46
+ "gt",
47
+ "gte",
48
+ "lt",
49
+ "lte",
50
+ "in",
51
+ "not_in",
52
+ "contains",
53
+ "missing",
54
+ "exists",
55
+ "regex",
56
+ "starts_with",
57
+ "ends_with",
58
+ ]
59
+ field: str = Field(..., min_length=1)
60
+ value: Any = None
61
+
62
+ @model_validator(mode="after")
63
+ def _check_value_required(self) -> FieldMatcher:
64
+ if self.kind in ("exists", "missing"):
65
+ return self
66
+ if self.value is None:
67
+ raise ValueError(f"matcher {self.kind!r} requires a `value`")
68
+ if self.kind in ("in", "not_in") and not isinstance(self.value, list):
69
+ raise ValueError(f"matcher {self.kind!r} requires `value` to be a list")
70
+ return self
71
+
72
+
73
+ class AllOfMatcher(StrictModel):
74
+ kind: Literal["all_of"] = "all_of"
75
+ matchers: list[Matcher] = Field(..., min_length=1)
76
+
77
+
78
+ class AnyOfMatcher(StrictModel):
79
+ kind: Literal["any_of"] = "any_of"
80
+ matchers: list[Matcher] = Field(..., min_length=1)
81
+
82
+
83
+ class NotMatcher(StrictModel):
84
+ kind: Literal["not"] = "not"
85
+ matcher: Matcher
86
+
87
+
88
+ class AlwaysMatcher(StrictModel):
89
+ """Useful as a catch-all final rule (effectively the default)."""
90
+
91
+ kind: Literal["always"] = "always"
92
+
93
+
94
+ Matcher = FieldMatcher | AllOfMatcher | AnyOfMatcher | NotMatcher | AlwaysMatcher
95
+
96
+
97
+ # Pydantic v2 forward-ref resolution for the recursive aliases.
98
+ AllOfMatcher.model_rebuild()
99
+ AnyOfMatcher.model_rebuild()
100
+ NotMatcher.model_rebuild()
101
+
102
+
103
+ # ---------------------------------------------------------------------------
104
+ # Rules / policies / bundles
105
+ # ---------------------------------------------------------------------------
106
+
107
+
108
+ class Rule(StrictModel):
109
+ id: str = Field(..., min_length=1)
110
+ effect: Effect
111
+ when: Matcher
112
+ description: str | None = None
113
+ tags: list[str] | None = None
114
+
115
+
116
+ class Policy(StrictModel):
117
+ """A named ordered list of rules. First match wins."""
118
+
119
+ id: str = Field(..., min_length=1)
120
+ description: str | None = None
121
+ default_effect: Effect = "deny"
122
+ rules: list[Rule] = Field(..., min_length=1)
123
+
124
+
125
+ class PolicyBundle(StrictModel):
126
+ """The unit a service loads at startup. Versioned."""
127
+
128
+ bundle_id: str = Field(..., min_length=1)
129
+ version: str = "0.1.0"
130
+ description: str | None = None
131
+ source: str | None = Field(
132
+ default=None,
133
+ description="Where the bundle came from (a Decision Card id, URL, file path).",
134
+ )
135
+ policies: list[Policy] = Field(..., min_length=1)
136
+
137
+
138
+ # ---------------------------------------------------------------------------
139
+ # Evaluation
140
+ # ---------------------------------------------------------------------------
141
+
142
+
143
+ class EvaluationContext(StrictModel):
144
+ """
145
+ The thing rules are evaluated against. Free-form `data` so callers can pour
146
+ in whatever shape the rules expect (subject, action, resource, claims, ...).
147
+ """
148
+
149
+ data: dict[str, Any] = Field(default_factory=dict)
150
+ subject: dict[str, Any] | None = None
151
+ action: str | None = None
152
+ resource: dict[str, Any] | None = None
153
+
154
+ def lookup(self, path: str) -> Any:
155
+ """
156
+ Dotted-path lookup over the merged context. Returns the sentinel
157
+ `_MISSING` when any segment doesn't exist, so `exists` / `missing`
158
+ matchers behave correctly.
159
+ """
160
+ merged: dict[str, Any] = {**self.data}
161
+ if self.subject is not None:
162
+ merged["subject"] = self.subject
163
+ if self.action is not None:
164
+ merged["action"] = self.action
165
+ if self.resource is not None:
166
+ merged["resource"] = self.resource
167
+
168
+ cur: Any = merged
169
+ for segment in path.split("."):
170
+ if isinstance(cur, dict) and segment in cur:
171
+ cur = cur[segment]
172
+ elif isinstance(cur, list):
173
+ try:
174
+ cur = cur[int(segment)]
175
+ except (ValueError, IndexError):
176
+ return _MISSING
177
+ else:
178
+ return _MISSING
179
+ return cur
180
+
181
+
182
+ _MISSING: Any = object()
183
+
184
+
185
+ class Decision(StrictModel):
186
+ kind: DecisionKind
187
+ matched_policy_id: str | None = None
188
+ matched_rule_id: str | None = None
189
+ reason: str | None = None
190
+
191
+
192
+ class EvaluationResult(StrictModel):
193
+ """
194
+ Bundle-wide result. Per-policy decisions are kept in `policy_decisions` so
195
+ operators can see *why* the final outcome happened. Combining rule:
196
+
197
+ - If ANY policy returns `deny`, the bundle returns `deny`.
198
+ - Else if ANY policy returns `allow`, the bundle returns `allow`.
199
+ - Else `not_applicable`.
200
+ """
201
+
202
+ bundle_id: str
203
+ decision: Decision
204
+ policy_decisions: list[Decision]
205
+
206
+ @classmethod
207
+ def combine(cls, bundle_id: str, policy_decisions: list[Decision]) -> EvaluationResult:
208
+ deny = next((d for d in policy_decisions if d.kind == "deny"), None)
209
+ if deny is not None:
210
+ return cls(bundle_id=bundle_id, decision=deny, policy_decisions=policy_decisions)
211
+ allow = next((d for d in policy_decisions if d.kind == "allow"), None)
212
+ if allow is not None:
213
+ return cls(bundle_id=bundle_id, decision=allow, policy_decisions=policy_decisions)
214
+ return cls(
215
+ bundle_id=bundle_id,
216
+ decision=Decision(kind="not_applicable", reason="no policy applied"),
217
+ policy_decisions=policy_decisions,
218
+ )
@@ -0,0 +1,278 @@
1
+ Metadata-Version: 2.4
2
+ Name: policy-as-code-engine
3
+ Version: 0.1.1
4
+ Summary: Declarative policy-as-code evaluator. JSON/YAML rules + Python predicates over arbitrary context objects. Enforces AI Procurement Decision Card conditions at request time. Optional audit-stream-py integration via AUDIT_STREAM_URL.
5
+ Project-URL: Homepage, https://github.com/mizcausevic-dev/policy-as-code-engine
6
+ Project-URL: Repository, https://github.com/mizcausevic-dev/policy-as-code-engine
7
+ Project-URL: Issues, https://github.com/mizcausevic-dev/policy-as-code-engine/issues
8
+ Project-URL: Decision Card spec, https://github.com/mizcausevic-dev/ai-procurement-decision-spec
9
+ Project-URL: Author Site, https://kineticgain.com/
10
+ Author-email: Miz Causevic <miz@kineticgain.com>
11
+ License: MIT
12
+ License-File: LICENSE
13
+ Keywords: ai-governance,decision-intelligence,fastapi,kinetic-gain-protocol-suite,policy,policy-as-code,rules-engine
14
+ Classifier: Development Status :: 4 - Beta
15
+ Classifier: Framework :: FastAPI
16
+ Classifier: Intended Audience :: Developers
17
+ Classifier: Intended Audience :: System Administrators
18
+ Classifier: License :: OSI Approved :: MIT License
19
+ Classifier: Operating System :: OS Independent
20
+ Classifier: Programming Language :: Python :: 3
21
+ Classifier: Programming Language :: Python :: 3.11
22
+ Classifier: Programming Language :: Python :: 3.12
23
+ Classifier: Programming Language :: Python :: 3.13
24
+ Classifier: Topic :: Software Development :: Libraries
25
+ Classifier: Typing :: Typed
26
+ Requires-Python: >=3.11
27
+ Requires-Dist: httpx>=0.27
28
+ Requires-Dist: pydantic>=2.7
29
+ Requires-Dist: pyyaml>=6.0
30
+ Provides-Extra: api
31
+ Requires-Dist: fastapi>=0.115; extra == 'api'
32
+ Requires-Dist: uvicorn[standard]>=0.30; extra == 'api'
33
+ Provides-Extra: dev
34
+ Requires-Dist: fastapi>=0.115; extra == 'dev'
35
+ Requires-Dist: httpx>=0.27; extra == 'dev'
36
+ Requires-Dist: mypy>=1.11; extra == 'dev'
37
+ Requires-Dist: pytest-asyncio>=0.23; extra == 'dev'
38
+ Requires-Dist: pytest>=8.2; extra == 'dev'
39
+ Requires-Dist: ruff>=0.6; extra == 'dev'
40
+ Requires-Dist: types-pyyaml>=6.0; extra == 'dev'
41
+ Requires-Dist: uvicorn[standard]>=0.30; extra == 'dev'
42
+ Description-Content-Type: text/markdown
43
+
44
+ # policy-as-code-engine
45
+
46
+ [![CI](https://github.com/mizcausevic-dev/policy-as-code-engine/actions/workflows/ci.yml/badge.svg)](https://github.com/mizcausevic-dev/policy-as-code-engine/actions/workflows/ci.yml)
47
+ [![Python](https://img.shields.io/badge/python-3.11%20%7C%203.12%20%7C%203.13-blue)](https://www.python.org/)
48
+ [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](LICENSE)
49
+
50
+ **Declarative policy-as-code evaluator for Python services.** JSON/YAML rules → first-match-wins evaluation → structured allow/deny decision with the matching rule and the reason. Cheap to embed; ships with a FastAPI surface; **pairs directly with [`procurement-decision-api`](https://github.com/mizcausevic-dev/procurement-decision-api)** so the same Decision Card that records a buyer's posture also becomes the runtime gate that enforces it.
51
+
52
+ ---
53
+
54
+ ## Why
55
+
56
+ Most policy engines either ask you to learn a DSL (Rego, Cedar) or hand you a dictionary-of-lambdas and call it a library. Neither is the right shape when the *source of truth* is a JSON document a human signed off on. This engine:
57
+
58
+ 1. **Reads JSON/YAML bundles.** No DSL. The matcher tree is the policy.
59
+ 2. **Returns *why*, not just *what*.** Every decision carries the matched policy + rule + reason. Operators get a real audit log on each evaluation.
60
+ 3. **Bridges to the Kinetic Gain Protocol Suite.** A single endpoint turns an AI Procurement Decision Card into a runtime-enforceable `PolicyBundle` — approve, reject, or approve-with-conditions all map to concrete allow/deny logic.
61
+
62
+ ---
63
+
64
+ ## Install
65
+
66
+ ```bash
67
+ pip install policy-as-code-engine
68
+ # with the FastAPI surface:
69
+ pip install "policy-as-code-engine[api]"
70
+ ```
71
+
72
+ Python 3.11+. Runtime deps: `pydantic` + `PyYAML`.
73
+
74
+ ---
75
+
76
+ ## Library quickstart
77
+
78
+ ```python
79
+ from policy_as_code_engine import (
80
+ EvaluationContext,
81
+ PolicyBundle,
82
+ PolicyEvaluator,
83
+ )
84
+
85
+ bundle = PolicyBundle.model_validate({
86
+ "bundle_id": "edu-gate",
87
+ "policies": [{
88
+ "id": "writes-require-admin",
89
+ "default_effect": "deny",
90
+ "rules": [
91
+ {
92
+ "id": "admin-writes",
93
+ "effect": "allow",
94
+ "when": {
95
+ "kind": "all_of",
96
+ "matchers": [
97
+ {"kind": "in", "field": "action", "value": ["create", "update", "delete"]},
98
+ {"kind": "eq", "field": "subject.role", "value": "admin"},
99
+ ],
100
+ },
101
+ },
102
+ ],
103
+ }],
104
+ })
105
+
106
+ ctx = EvaluationContext(
107
+ subject={"id": "u-42", "role": "admin"},
108
+ action="update",
109
+ resource={"id": "doc-7"},
110
+ )
111
+
112
+ result = PolicyEvaluator().evaluate(bundle, ctx)
113
+ print(result.decision.kind) # "allow"
114
+ print(result.decision.matched_rule_id) # "admin-writes"
115
+ print(result.decision.reason) # "matched rule 'admin-writes'"
116
+ ```
117
+
118
+ `result.policy_decisions` carries every per-policy outcome — drop it straight into your audit log.
119
+
120
+ ---
121
+
122
+ ## Bundle DSL
123
+
124
+ A bundle is a small recursive structure. Matchers compose; rules ordered.
125
+
126
+ ### Field matchers
127
+
128
+ | Kind | Notes |
129
+ | --------------- | --- |
130
+ | `eq` / `ne` | Strict equality. |
131
+ | `gt` / `gte` / `lt` / `lte` | Comparison; returns `false` on incompatible types (won't raise). |
132
+ | `in` / `not_in` | `value` must be a list. |
133
+ | `contains` | Works against strings, lists, sets, dicts. |
134
+ | `exists` / `missing` | No `value`. Operates against the dotted-path resolver. |
135
+ | `regex` | Compiled patterns are cached per-evaluator. |
136
+ | `starts_with` / `ends_with` | String-only. |
137
+
138
+ ### Composite matchers
139
+
140
+ | Kind | Children | Truth |
141
+ | -------- | -------- | --- |
142
+ | `all_of` | `matchers: [...]` | All children true. |
143
+ | `any_of` | `matchers: [...]` | At least one child true. |
144
+ | `not` | `matcher: {...}` | Inverts the child. |
145
+ | `always` | — | Always true. Useful as a final catch-all. |
146
+
147
+ ### Dotted paths
148
+
149
+ The resolver looks at the merged context (`data` + `subject` + `action` + `resource`):
150
+
151
+ ```
152
+ subject.role
153
+ resource.tags.0 # list index
154
+ data.conditions_satisfied.dpa-signed
155
+ ```
156
+
157
+ Missing segments produce a `_MISSING` sentinel — `exists` / `missing` matchers see it; every other matcher returns `false`.
158
+
159
+ ---
160
+
161
+ ## FastAPI surface
162
+
163
+ ```bash
164
+ pip install "policy-as-code-engine[api]"
165
+ python -m policy_as_code_engine # binds 0.0.0.0:8089 by default
166
+ ```
167
+
168
+ | Method | Path | What it does |
169
+ | --- | --- | --- |
170
+ | GET | `/healthz` | Liveness probe. |
171
+ | GET | `/` | Service info. |
172
+ | POST | `/bundles` | Register a `PolicyBundle` in memory. |
173
+ | GET | `/bundles` | List registered bundle IDs. |
174
+ | GET | `/bundles/{bundle_id}` | Inspect a registered bundle. |
175
+ | POST | `/bundles/{bundle_id}/evaluate` | Evaluate a stored bundle against an `EvaluationContext`. |
176
+ | POST | `/evaluate` | One-shot. Bundle + context in, decision out. |
177
+ | POST | `/bundles/from-decision-card` | **The cross-ecosystem hook.** Turn a Kinetic Gain Procurement Decision Card into a `PolicyBundle` and register it. |
178
+
179
+ ---
180
+
181
+ ## The cross-ecosystem hook
182
+
183
+ The headline feature. An AI Procurement Decision Card is the buyer-side record that says "we evaluated this vendor and our position is X." This engine turns that human-authored artifact into a runtime gate, mechanically.
184
+
185
+ ```bash
186
+ curl -X POST http://localhost:8089/bundles/from-decision-card \
187
+ -H 'Content-Type: application/json' \
188
+ -d @decision-card.json
189
+ ```
190
+
191
+ Mapping:
192
+
193
+ | Decision Card status | Resulting bundle |
194
+ | --- | --- |
195
+ | `approved` | Single `allow-all` policy. |
196
+ | `rejected` · `rejected-with-remediation` · `withdrawn` · `expired` · `pending` | Single `deny-all` policy (fail safe). |
197
+ | `approved-with-conditions` | One policy *per* condition. Each policy `allow`s only when `conditions_satisfied.{condition_id}` is `true` in the evaluation context; `deny` otherwise. The bundle combiner does deny-trumps-allow, so **every** condition must be satisfied to allow. |
198
+
199
+ Wire your own satisfaction signal — DPA verifier, bias-audit freshness check, attestation timestamp — into the context, and the bundle does the rest.
200
+
201
+ ```python
202
+ from policy_as_code_engine import (
203
+ EvaluationContext,
204
+ PolicyEvaluator,
205
+ policy_bundle_from_decision_card,
206
+ )
207
+
208
+ card = {...} # POST /decisions/draft output from procurement-decision-api
209
+ bundle = policy_bundle_from_decision_card(card)
210
+
211
+ ctx = EvaluationContext(
212
+ subject={"id": "u-1"},
213
+ action="enroll",
214
+ data={
215
+ "conditions_satisfied": {
216
+ "dpa-signed": True,
217
+ "bias-audit-fresh": True,
218
+ }
219
+ },
220
+ )
221
+
222
+ decision = PolicyEvaluator().evaluate(bundle, ctx).decision
223
+ ```
224
+
225
+ ---
226
+
227
+ ## CLI
228
+
229
+ ```bash
230
+ python -m policy_as_code_engine eval examples/example-bundle.yaml examples/example-context.json
231
+ ```
232
+
233
+ Prints the full `EvaluationResult` as JSON. Exits non-zero on `deny`.
234
+
235
+ ---
236
+
237
+ ## How decisions combine
238
+
239
+ Inside a single policy: **first matching rule wins**, otherwise `default_effect`.
240
+
241
+ Across the bundle:
242
+
243
+ ```
244
+ deny -> deny (any policy denies => bundle denies)
245
+ allow -> allow (otherwise, any allow => bundle allows)
246
+ neither -> not_applicable
247
+ ```
248
+
249
+ Per-policy decisions are always returned — useful for "we denied because of policy B, but A would have allowed" audit narratives.
250
+
251
+ ---
252
+
253
+ ## Tests
254
+
255
+ ```bash
256
+ pip install -e ".[dev]"
257
+ ruff check src tests && ruff format --check src tests
258
+ mypy src
259
+ pytest -v
260
+ ```
261
+
262
+ CI matrix runs Python 3.11 / 3.12 / 3.13.
263
+
264
+ ---
265
+
266
+ ## Related in this ecosystem
267
+
268
+ - **[procurement-decision-api](https://github.com/mizcausevic-dev/procurement-decision-api)** — drafts the Decision Cards that this engine enforces.
269
+ - **[ai-procurement-decision-spec](https://github.com/mizcausevic-dev/ai-procurement-decision-spec)** — the v0.1 schema.
270
+ - **[slo-budget-tracker](https://github.com/mizcausevic-dev/slo-budget-tracker)** — error-budget tracker that you can wire into the same FastAPI app.
271
+ - **[reliability-toolkit-rs](https://github.com/mizcausevic-dev/reliability-toolkit-rs)** — Rust async reliability primitives.
272
+ - More at [kineticgain.com](https://kineticgain.com/).
273
+
274
+ ---
275
+
276
+ ## License
277
+
278
+ MIT. See [LICENSE](LICENSE).
@@ -0,0 +1,13 @@
1
+ policy_as_code_engine/__init__.py,sha256=YdLkvc2VuV3HU8BmrOSrHvABbNjUtaZxUZq4sJBllrg,1399
2
+ policy_as_code_engine/__main__.py,sha256=HArqi8lokgiY41lvNeOmZkbTidToHqscch75L9c_iF8,1493
3
+ policy_as_code_engine/app.py,sha256=rJJZHZnw47nFIUaYSbdseYOwp1ag9sRoO62m8LrI4Yo,7582
4
+ policy_as_code_engine/audit_stream.py,sha256=3Y0YvgvmQzpUVlR9WpWb0bH7KidMHY_ap9OXjdMUqiY,2376
5
+ policy_as_code_engine/evaluator.py,sha256=4PufA3teQXr_6J5rl8pNk_bNzlJ14uyDHO_3HlxyVxs,7161
6
+ policy_as_code_engine/from_decision_card.py,sha256=uYVLCOHHrgIsCbx6yse5p5t5QTMV5JAfCO2No_u8IlM,6155
7
+ policy_as_code_engine/loader.py,sha256=e0ZyWe8qO5j2X3vGaTTBJoFMfuiEuBMZNK2Ep1iPFi8,1313
8
+ policy_as_code_engine/models.py,sha256=ff418E8NjpWu5IuhdHa3awaO7Q-1iSfg8B4pAsOVZu0,6805
9
+ policy_as_code_engine-0.1.1.dist-info/METADATA,sha256=PiIeJwq_ziwTkJdo3wTpTVpjDX09h4f_X9z5Jdcub1Q,10222
10
+ policy_as_code_engine-0.1.1.dist-info/WHEEL,sha256=QccIxa26bgl1E6uMy58deGWi-0aeIkkangHcxk2kWfw,87
11
+ policy_as_code_engine-0.1.1.dist-info/entry_points.txt,sha256=M4F2M0hYy4uU4DgGDelJczeh4homL8wEV5QXxCQ8VZA,68
12
+ policy_as_code_engine-0.1.1.dist-info/licenses/LICENSE,sha256=9REhXrEWXVgwUgFKw9H__Zaboj9yGUNNVJeYTlmqvG8,1084
13
+ policy_as_code_engine-0.1.1.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.29.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ policy-eval = policy_as_code_engine.__main__:main
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Miz Causevic / Kinetic Gain
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.