spanforge 1.0.0__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.
Files changed (174) hide show
  1. spanforge/__init__.py +815 -0
  2. spanforge/_ansi.py +93 -0
  3. spanforge/_batch_exporter.py +409 -0
  4. spanforge/_cli.py +2094 -0
  5. spanforge/_cli_audit.py +639 -0
  6. spanforge/_cli_compliance.py +711 -0
  7. spanforge/_cli_cost.py +243 -0
  8. spanforge/_cli_ops.py +791 -0
  9. spanforge/_cli_phase11.py +356 -0
  10. spanforge/_hooks.py +337 -0
  11. spanforge/_server.py +1708 -0
  12. spanforge/_span.py +1036 -0
  13. spanforge/_store.py +288 -0
  14. spanforge/_stream.py +664 -0
  15. spanforge/_trace.py +335 -0
  16. spanforge/_tracer.py +254 -0
  17. spanforge/actor.py +141 -0
  18. spanforge/alerts.py +469 -0
  19. spanforge/auto.py +464 -0
  20. spanforge/baseline.py +335 -0
  21. spanforge/cache.py +635 -0
  22. spanforge/compliance.py +325 -0
  23. spanforge/config.py +532 -0
  24. spanforge/consent.py +228 -0
  25. spanforge/consumer.py +377 -0
  26. spanforge/core/__init__.py +5 -0
  27. spanforge/core/compliance_mapping.py +1254 -0
  28. spanforge/cost.py +600 -0
  29. spanforge/debug.py +548 -0
  30. spanforge/deprecations.py +205 -0
  31. spanforge/drift.py +482 -0
  32. spanforge/egress.py +58 -0
  33. spanforge/eval.py +648 -0
  34. spanforge/event.py +1064 -0
  35. spanforge/exceptions.py +240 -0
  36. spanforge/explain.py +178 -0
  37. spanforge/export/__init__.py +69 -0
  38. spanforge/export/append_only.py +337 -0
  39. spanforge/export/cloud.py +357 -0
  40. spanforge/export/datadog.py +497 -0
  41. spanforge/export/grafana.py +320 -0
  42. spanforge/export/jsonl.py +195 -0
  43. spanforge/export/openinference.py +158 -0
  44. spanforge/export/otel_bridge.py +294 -0
  45. spanforge/export/otlp.py +811 -0
  46. spanforge/export/otlp_bridge.py +233 -0
  47. spanforge/export/redis_backend.py +282 -0
  48. spanforge/export/siem_schema.py +98 -0
  49. spanforge/export/siem_splunk.py +264 -0
  50. spanforge/export/siem_syslog.py +212 -0
  51. spanforge/export/webhook.py +299 -0
  52. spanforge/exporters/__init__.py +30 -0
  53. spanforge/exporters/console.py +271 -0
  54. spanforge/exporters/jsonl.py +144 -0
  55. spanforge/exporters/sqlite.py +142 -0
  56. spanforge/gate.py +1150 -0
  57. spanforge/governance.py +181 -0
  58. spanforge/hitl.py +295 -0
  59. spanforge/http.py +187 -0
  60. spanforge/inspect.py +427 -0
  61. spanforge/integrations/__init__.py +45 -0
  62. spanforge/integrations/_pricing.py +280 -0
  63. spanforge/integrations/anthropic.py +388 -0
  64. spanforge/integrations/azure_openai.py +133 -0
  65. spanforge/integrations/bedrock.py +292 -0
  66. spanforge/integrations/crewai.py +251 -0
  67. spanforge/integrations/gemini.py +351 -0
  68. spanforge/integrations/groq.py +442 -0
  69. spanforge/integrations/langchain.py +349 -0
  70. spanforge/integrations/langgraph.py +306 -0
  71. spanforge/integrations/llamaindex.py +373 -0
  72. spanforge/integrations/ollama.py +287 -0
  73. spanforge/integrations/openai.py +368 -0
  74. spanforge/integrations/together.py +483 -0
  75. spanforge/io.py +214 -0
  76. spanforge/lint.py +322 -0
  77. spanforge/metrics.py +417 -0
  78. spanforge/metrics_export.py +343 -0
  79. spanforge/migrate.py +402 -0
  80. spanforge/model_registry.py +278 -0
  81. spanforge/models.py +389 -0
  82. spanforge/namespaces/__init__.py +254 -0
  83. spanforge/namespaces/audit.py +256 -0
  84. spanforge/namespaces/cache.py +237 -0
  85. spanforge/namespaces/chain.py +77 -0
  86. spanforge/namespaces/confidence.py +72 -0
  87. spanforge/namespaces/consent.py +92 -0
  88. spanforge/namespaces/cost.py +179 -0
  89. spanforge/namespaces/decision.py +143 -0
  90. spanforge/namespaces/diff.py +157 -0
  91. spanforge/namespaces/drift.py +80 -0
  92. spanforge/namespaces/eval_.py +251 -0
  93. spanforge/namespaces/feedback.py +241 -0
  94. spanforge/namespaces/fence.py +193 -0
  95. spanforge/namespaces/guard.py +105 -0
  96. spanforge/namespaces/hitl.py +91 -0
  97. spanforge/namespaces/latency.py +72 -0
  98. spanforge/namespaces/prompt.py +190 -0
  99. spanforge/namespaces/redact.py +173 -0
  100. spanforge/namespaces/retrieval.py +379 -0
  101. spanforge/namespaces/runtime_governance.py +494 -0
  102. spanforge/namespaces/template.py +208 -0
  103. spanforge/namespaces/tool_call.py +77 -0
  104. spanforge/namespaces/trace.py +1029 -0
  105. spanforge/normalizer.py +171 -0
  106. spanforge/plugins.py +82 -0
  107. spanforge/presidio_backend.py +349 -0
  108. spanforge/processor.py +258 -0
  109. spanforge/prompt_registry.py +418 -0
  110. spanforge/py.typed +0 -0
  111. spanforge/redact.py +914 -0
  112. spanforge/regression.py +192 -0
  113. spanforge/runtime_policy.py +159 -0
  114. spanforge/sampling.py +511 -0
  115. spanforge/schema.py +183 -0
  116. spanforge/schemas/v1.0/schema.json +170 -0
  117. spanforge/schemas/v2.0/schema.json +536 -0
  118. spanforge/sdk/__init__.py +625 -0
  119. spanforge/sdk/_base.py +584 -0
  120. spanforge/sdk/_base.pyi +71 -0
  121. spanforge/sdk/_exceptions.py +1096 -0
  122. spanforge/sdk/_types.py +2184 -0
  123. spanforge/sdk/alert.py +1514 -0
  124. spanforge/sdk/alert.pyi +56 -0
  125. spanforge/sdk/audit.py +1196 -0
  126. spanforge/sdk/audit.pyi +67 -0
  127. spanforge/sdk/cec.py +1215 -0
  128. spanforge/sdk/cec.pyi +37 -0
  129. spanforge/sdk/config.py +641 -0
  130. spanforge/sdk/config.pyi +55 -0
  131. spanforge/sdk/enterprise.py +714 -0
  132. spanforge/sdk/enterprise.pyi +79 -0
  133. spanforge/sdk/explain.py +170 -0
  134. spanforge/sdk/fallback.py +432 -0
  135. spanforge/sdk/feedback.py +351 -0
  136. spanforge/sdk/gate.py +874 -0
  137. spanforge/sdk/gate.pyi +51 -0
  138. spanforge/sdk/identity.py +2114 -0
  139. spanforge/sdk/identity.pyi +47 -0
  140. spanforge/sdk/lineage.py +175 -0
  141. spanforge/sdk/observe.py +1065 -0
  142. spanforge/sdk/observe.pyi +50 -0
  143. spanforge/sdk/operator.py +338 -0
  144. spanforge/sdk/pii.py +1473 -0
  145. spanforge/sdk/pii.pyi +119 -0
  146. spanforge/sdk/pipelines.py +458 -0
  147. spanforge/sdk/pipelines.pyi +39 -0
  148. spanforge/sdk/policy.py +930 -0
  149. spanforge/sdk/rag.py +594 -0
  150. spanforge/sdk/rbac.py +280 -0
  151. spanforge/sdk/registry.py +430 -0
  152. spanforge/sdk/registry.pyi +46 -0
  153. spanforge/sdk/scope.py +279 -0
  154. spanforge/sdk/secrets.py +293 -0
  155. spanforge/sdk/secrets.pyi +25 -0
  156. spanforge/sdk/security.py +560 -0
  157. spanforge/sdk/security.pyi +57 -0
  158. spanforge/sdk/trust.py +472 -0
  159. spanforge/sdk/trust.pyi +41 -0
  160. spanforge/secrets.py +799 -0
  161. spanforge/signing.py +1179 -0
  162. spanforge/stats.py +100 -0
  163. spanforge/stream.py +560 -0
  164. spanforge/testing.py +378 -0
  165. spanforge/testing_mocks.py +1052 -0
  166. spanforge/trace.py +199 -0
  167. spanforge/types.py +696 -0
  168. spanforge/ulid.py +300 -0
  169. spanforge/validate.py +379 -0
  170. spanforge-1.0.0.dist-info/METADATA +1509 -0
  171. spanforge-1.0.0.dist-info/RECORD +174 -0
  172. spanforge-1.0.0.dist-info/WHEEL +4 -0
  173. spanforge-1.0.0.dist-info/entry_points.txt +5 -0
  174. spanforge-1.0.0.dist-info/licenses/LICENSE +128 -0
@@ -0,0 +1,930 @@
1
+ """spanforge.sdk.policy - Runtime policy engine for GA governance controls."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import copy
6
+ import threading
7
+ from dataclasses import dataclass, field
8
+ from typing import Any
9
+
10
+ from spanforge.runtime_policy import RuntimePolicyBundle, RuntimePolicyRule
11
+ from spanforge.sdk._base import SFClientConfig, SFServiceClient
12
+
13
+ __all__ = [
14
+ "RuntimePolicyComparisonResult",
15
+ "RuntimePolicyDecision",
16
+ "RuntimePolicyReplayResult",
17
+ "RuntimePolicyReviewRecord",
18
+ "RuntimePolicySimulationResult",
19
+ "RuntimePolicyStatusInfo",
20
+ "SFPolicyClient",
21
+ ]
22
+
23
+ _ALLOWED_DECISION_ACTIONS = frozenset({"allow", "allow+log", "redact", "block", "human_review"})
24
+ _ALLOWED_REVIEW_CLASSIFICATIONS = frozenset({"false_positive", "true_positive", "needs_tuning"})
25
+
26
+
27
+ @dataclass
28
+ class RuntimePolicyDecision:
29
+ """Auditable result of one runtime policy evaluation."""
30
+
31
+ decision_id: str
32
+ policy_id: str
33
+ policy_version: str
34
+ environment: str
35
+ service: str
36
+ control: str
37
+ action: str
38
+ allowed: bool
39
+ evaluated_at: str
40
+ reason: str
41
+ rule_id: str | None = None
42
+ threshold: float | None = None
43
+ observed_value: float | None = None
44
+ metadata: dict[str, Any] = field(default_factory=dict)
45
+
46
+ def __post_init__(self) -> None:
47
+ if not self.decision_id:
48
+ raise ValueError("RuntimePolicyDecision.decision_id must be non-empty")
49
+ if not self.policy_id:
50
+ raise ValueError("RuntimePolicyDecision.policy_id must be non-empty")
51
+ if not self.policy_version:
52
+ raise ValueError("RuntimePolicyDecision.policy_version must be non-empty")
53
+ if not self.environment:
54
+ raise ValueError("RuntimePolicyDecision.environment must be non-empty")
55
+ if not self.service:
56
+ raise ValueError("RuntimePolicyDecision.service must be non-empty")
57
+ if not self.control:
58
+ raise ValueError("RuntimePolicyDecision.control must be non-empty")
59
+ if self.action not in _ALLOWED_DECISION_ACTIONS:
60
+ raise ValueError(
61
+ f"RuntimePolicyDecision.action must be one of {sorted(_ALLOWED_DECISION_ACTIONS)}"
62
+ )
63
+ if not self.evaluated_at:
64
+ raise ValueError("RuntimePolicyDecision.evaluated_at must be non-empty")
65
+ if not self.reason:
66
+ raise ValueError("RuntimePolicyDecision.reason must be non-empty")
67
+ if self.threshold is not None and not (0.0 <= self.threshold <= 1.0):
68
+ raise ValueError("RuntimePolicyDecision.threshold must be in [0.0, 1.0]")
69
+ if self.observed_value is not None and not (0.0 <= self.observed_value <= 1.0):
70
+ raise ValueError("RuntimePolicyDecision.observed_value must be in [0.0, 1.0]")
71
+
72
+ def to_dict(self) -> dict[str, Any]:
73
+ data: dict[str, Any] = {
74
+ "decision_id": self.decision_id,
75
+ "policy_id": self.policy_id,
76
+ "policy_version": self.policy_version,
77
+ "environment": self.environment,
78
+ "service": self.service,
79
+ "control": self.control,
80
+ "action": self.action,
81
+ "allowed": self.allowed,
82
+ "evaluated_at": self.evaluated_at,
83
+ "reason": self.reason,
84
+ }
85
+ if self.rule_id is not None:
86
+ data["rule_id"] = self.rule_id
87
+ if self.threshold is not None:
88
+ data["threshold"] = self.threshold
89
+ if self.observed_value is not None:
90
+ data["observed_value"] = self.observed_value
91
+ if self.metadata:
92
+ data["metadata"] = self.metadata
93
+ return data
94
+
95
+
96
+ @dataclass
97
+ class RuntimePolicySimulationResult:
98
+ """One non-production policy simulation result."""
99
+
100
+ simulation_id: str
101
+ trace_id: str
102
+ environment: str
103
+ service: str
104
+ control: str
105
+ candidate_policy_id: str
106
+ candidate_policy_version: str
107
+ simulated_at: str
108
+ candidate_decision: RuntimePolicyDecision
109
+ production_decision: RuntimePolicyDecision | None = None
110
+ changed: bool = False
111
+
112
+ def to_dict(self) -> dict[str, Any]:
113
+ data: dict[str, Any] = {
114
+ "simulation_id": self.simulation_id,
115
+ "trace_id": self.trace_id,
116
+ "environment": self.environment,
117
+ "service": self.service,
118
+ "control": self.control,
119
+ "candidate_policy_id": self.candidate_policy_id,
120
+ "candidate_policy_version": self.candidate_policy_version,
121
+ "simulated_at": self.simulated_at,
122
+ "changed": self.changed,
123
+ "candidate_decision": self.candidate_decision.to_dict(),
124
+ }
125
+ if self.production_decision is not None:
126
+ data["production_decision"] = self.production_decision.to_dict()
127
+ return data
128
+
129
+
130
+ @dataclass
131
+ class RuntimePolicyReplayResult:
132
+ """Summary of replaying historical events through a policy bundle."""
133
+
134
+ replay_id: str
135
+ environment: str
136
+ policy_id: str
137
+ policy_version: str
138
+ replayed_at: str
139
+ event_count: int
140
+ changed_count: int
141
+ blocked_count: int
142
+ review_count: int
143
+ simulations: list[RuntimePolicySimulationResult] = field(default_factory=list)
144
+
145
+ def to_dict(self) -> dict[str, Any]:
146
+ return {
147
+ "replay_id": self.replay_id,
148
+ "environment": self.environment,
149
+ "policy_id": self.policy_id,
150
+ "policy_version": self.policy_version,
151
+ "replayed_at": self.replayed_at,
152
+ "event_count": self.event_count,
153
+ "changed_count": self.changed_count,
154
+ "blocked_count": self.blocked_count,
155
+ "review_count": self.review_count,
156
+ "simulations": [item.to_dict() for item in self.simulations],
157
+ }
158
+
159
+
160
+ @dataclass
161
+ class RuntimePolicyComparisonResult:
162
+ """Comparison summary between baseline and candidate policy outcomes."""
163
+
164
+ comparison_id: str
165
+ environment: str
166
+ baseline_policy_id: str
167
+ baseline_policy_version: str
168
+ candidate_policy_id: str
169
+ candidate_policy_version: str
170
+ compared_at: str
171
+ event_count: int
172
+ changed_count: int
173
+ action_changes: dict[str, int] = field(default_factory=dict)
174
+
175
+ def to_dict(self) -> dict[str, Any]:
176
+ return {
177
+ "comparison_id": self.comparison_id,
178
+ "environment": self.environment,
179
+ "baseline_policy_id": self.baseline_policy_id,
180
+ "baseline_policy_version": self.baseline_policy_version,
181
+ "candidate_policy_id": self.candidate_policy_id,
182
+ "candidate_policy_version": self.candidate_policy_version,
183
+ "compared_at": self.compared_at,
184
+ "event_count": self.event_count,
185
+ "changed_count": self.changed_count,
186
+ "action_changes": dict(self.action_changes),
187
+ }
188
+
189
+
190
+ @dataclass
191
+ class RuntimePolicyReviewRecord:
192
+ """Basic false-positive review loop record."""
193
+
194
+ review_id: str
195
+ decision_id: str
196
+ trace_id: str
197
+ environment: str
198
+ service: str
199
+ control: str
200
+ action: str
201
+ policy_id: str
202
+ policy_version: str
203
+ classification: str
204
+ recorded_at: str
205
+ notes: str = ""
206
+
207
+ def __post_init__(self) -> None:
208
+ if self.classification not in _ALLOWED_REVIEW_CLASSIFICATIONS:
209
+ raise ValueError(
210
+ "RuntimePolicyReviewRecord.classification must be one of "
211
+ f"{sorted(_ALLOWED_REVIEW_CLASSIFICATIONS)}"
212
+ )
213
+
214
+ def to_dict(self) -> dict[str, Any]:
215
+ data: dict[str, Any] = {
216
+ "review_id": self.review_id,
217
+ "decision_id": self.decision_id,
218
+ "trace_id": self.trace_id,
219
+ "environment": self.environment,
220
+ "service": self.service,
221
+ "control": self.control,
222
+ "action": self.action,
223
+ "policy_id": self.policy_id,
224
+ "policy_version": self.policy_version,
225
+ "classification": self.classification,
226
+ "recorded_at": self.recorded_at,
227
+ }
228
+ if self.notes:
229
+ data["notes"] = self.notes
230
+ return data
231
+
232
+
233
+ @dataclass
234
+ class RuntimePolicyStatusInfo:
235
+ """Runtime policy engine status."""
236
+
237
+ status: str
238
+ loaded_bundles: int
239
+ active_environments: int
240
+ decisions_emitted: int
241
+ simulations_emitted: int = 0
242
+ replays_emitted: int = 0
243
+ reviews_recorded: int = 0
244
+
245
+
246
+ class SFPolicyClient(SFServiceClient):
247
+ """Runtime policy loading, activation, evaluation, and promotion."""
248
+
249
+ def __init__(self, config: SFClientConfig) -> None:
250
+ super().__init__(config, service_name="policy")
251
+ self._lock = threading.Lock()
252
+ self._bundles: dict[tuple[str, str, str], RuntimePolicyBundle] = {}
253
+ self._active_by_environment: dict[str, tuple[str, str]] = {}
254
+ self._decision_records: dict[str, RuntimePolicyDecision] = {}
255
+ self._decisions_by_trace: dict[str, list[str]] = {}
256
+ self._simulation_records: dict[str, RuntimePolicySimulationResult] = {}
257
+ self._simulations_by_trace: dict[str, list[str]] = {}
258
+ self._replay_records: dict[str, RuntimePolicyReplayResult] = {}
259
+ self._review_records: dict[str, RuntimePolicyReviewRecord] = {}
260
+ self._reviews_by_trace: dict[str, list[str]] = {}
261
+ self._decisions_emitted = 0
262
+ self._simulations_emitted = 0
263
+ self._replays_emitted = 0
264
+ self._reviews_recorded = 0
265
+
266
+ def load_bundle(self, bundle: RuntimePolicyBundle | dict[str, Any]) -> RuntimePolicyBundle:
267
+ """Load and validate a bundle into the local policy registry."""
268
+ parsed = bundle if isinstance(bundle, RuntimePolicyBundle) else RuntimePolicyBundle.from_dict(bundle)
269
+ key = (parsed.environment, parsed.policy_id, parsed.version)
270
+ with self._lock:
271
+ self._bundles[key] = parsed
272
+ return parsed
273
+
274
+ def validate_bundle(self, bundle: RuntimePolicyBundle | dict[str, Any]) -> RuntimePolicyBundle:
275
+ """Validate and return a parsed runtime policy bundle."""
276
+ return bundle if isinstance(bundle, RuntimePolicyBundle) else RuntimePolicyBundle.from_dict(bundle)
277
+
278
+ def activate(
279
+ self,
280
+ *,
281
+ environment: str,
282
+ policy_id: str,
283
+ version: str,
284
+ activated_at: str,
285
+ ) -> RuntimePolicyBundle:
286
+ """Activate one loaded bundle for an environment."""
287
+ key = (environment, policy_id, version)
288
+ with self._lock:
289
+ bundle = self._bundles[key]
290
+ self._active_by_environment[environment] = (policy_id, version)
291
+ self._emit_policy_event(
292
+ {
293
+ "event_type": "policy_activated",
294
+ "environment": environment,
295
+ "policy_id": policy_id,
296
+ "version": version,
297
+ "activated_at": activated_at,
298
+ }
299
+ )
300
+ return bundle
301
+
302
+ def deactivate(self, *, environment: str, deactivated_at: str) -> None:
303
+ """Deactivate the currently active policy for an environment."""
304
+ with self._lock:
305
+ policy_id, version = self._active_by_environment.pop(environment)
306
+ self._emit_policy_event(
307
+ {
308
+ "event_type": "policy_deactivated",
309
+ "environment": environment,
310
+ "policy_id": policy_id,
311
+ "version": version,
312
+ "deactivated_at": deactivated_at,
313
+ }
314
+ )
315
+
316
+ def get_active_bundle(self, environment: str) -> RuntimePolicyBundle | None:
317
+ """Return the active bundle for an environment, if any."""
318
+ with self._lock:
319
+ active = self._active_by_environment.get(environment)
320
+ if active is None:
321
+ return None
322
+ return self._bundles.get((environment, active[0], active[1]))
323
+
324
+ def list_versions(self, *, environment: str, policy_id: str) -> list[RuntimePolicyBundle]:
325
+ """List loaded versions for one policy in one environment."""
326
+ with self._lock:
327
+ bundles = [
328
+ bundle
329
+ for (env, pid, _version), bundle in self._bundles.items()
330
+ if env == environment and pid == policy_id
331
+ ]
332
+ return sorted(bundles, key=lambda item: item.version)
333
+
334
+ def promote(
335
+ self,
336
+ *,
337
+ policy_id: str,
338
+ from_environment: str,
339
+ to_environment: str,
340
+ version: str,
341
+ new_version: str,
342
+ owner: str,
343
+ effective_at: str,
344
+ ) -> RuntimePolicyBundle:
345
+ """Clone one loaded policy version into another environment."""
346
+ with self._lock:
347
+ source = self._bundles[(from_environment, policy_id, version)]
348
+ promoted = RuntimePolicyBundle(
349
+ policy_id=source.policy_id,
350
+ version=new_version,
351
+ environment=to_environment,
352
+ owner=owner,
353
+ effective_at=effective_at,
354
+ rules=[
355
+ RuntimePolicyRule.from_dict(copy.deepcopy(rule.to_dict()))
356
+ for rule in source.rules
357
+ ],
358
+ rationale=source.rationale,
359
+ metadata=dict(source.metadata),
360
+ )
361
+ self.load_bundle(promoted)
362
+ self._emit_policy_event(
363
+ {
364
+ "event_type": "policy_promoted",
365
+ "policy_id": policy_id,
366
+ "from_environment": from_environment,
367
+ "to_environment": to_environment,
368
+ "source_version": version,
369
+ "new_version": new_version,
370
+ "effective_at": effective_at,
371
+ }
372
+ )
373
+ return promoted
374
+
375
+ def evaluate(
376
+ self,
377
+ *,
378
+ environment: str,
379
+ trace_id: str,
380
+ service: str,
381
+ control: str,
382
+ evaluated_at: str,
383
+ observed_value: float | None = None,
384
+ metadata: dict[str, Any] | None = None,
385
+ ) -> RuntimePolicyDecision:
386
+ """Evaluate one service/control pair against the active environment bundle."""
387
+ bundle = self.get_active_bundle(environment)
388
+ decision = self._evaluate_bundle(
389
+ bundle=bundle,
390
+ environment=environment,
391
+ service=service,
392
+ control=control,
393
+ evaluated_at=evaluated_at,
394
+ observed_value=observed_value,
395
+ metadata=metadata or {},
396
+ )
397
+
398
+ with self._lock:
399
+ self._decision_records[decision.decision_id] = decision
400
+ self._decisions_by_trace.setdefault(trace_id, []).append(decision.decision_id)
401
+ self._decisions_emitted += 1
402
+
403
+ self._emit_policy_decision(trace_id=trace_id, decision=decision)
404
+ return decision
405
+
406
+ def simulate(
407
+ self,
408
+ *,
409
+ environment: str,
410
+ trace_id: str,
411
+ service: str,
412
+ control: str,
413
+ simulated_at: str,
414
+ candidate_bundle: RuntimePolicyBundle | dict[str, Any],
415
+ observed_value: float | None = None,
416
+ metadata: dict[str, Any] | None = None,
417
+ production_decision: RuntimePolicyDecision | None = None,
418
+ ) -> RuntimePolicySimulationResult:
419
+ """Simulate one candidate bundle without changing live policy state."""
420
+ from spanforge.ulid import generate as _ulid
421
+
422
+ parsed_bundle = self.validate_bundle(candidate_bundle)
423
+ if parsed_bundle.environment != environment:
424
+ raise ValueError("candidate_bundle.environment must match simulation environment")
425
+
426
+ simulated_decision = self._evaluate_bundle(
427
+ bundle=parsed_bundle,
428
+ environment=environment,
429
+ service=service,
430
+ control=control,
431
+ evaluated_at=simulated_at,
432
+ observed_value=observed_value,
433
+ metadata=metadata or {},
434
+ )
435
+ changed = self._decision_changed(production_decision, simulated_decision)
436
+ result = RuntimePolicySimulationResult(
437
+ simulation_id=_ulid(),
438
+ trace_id=trace_id,
439
+ environment=environment,
440
+ service=service,
441
+ control=control,
442
+ candidate_policy_id=parsed_bundle.policy_id,
443
+ candidate_policy_version=parsed_bundle.version,
444
+ simulated_at=simulated_at,
445
+ candidate_decision=simulated_decision,
446
+ production_decision=production_decision,
447
+ changed=changed,
448
+ )
449
+
450
+ with self._lock:
451
+ self._simulation_records[result.simulation_id] = result
452
+ self._simulations_by_trace.setdefault(trace_id, []).append(result.simulation_id)
453
+ self._simulations_emitted += 1
454
+
455
+ self._emit_policy_simulation(result)
456
+ return result
457
+
458
+ def replay(
459
+ self,
460
+ *,
461
+ environment: str,
462
+ replayed_at: str,
463
+ events: list[dict[str, Any]],
464
+ candidate_bundle: RuntimePolicyBundle | dict[str, Any],
465
+ ) -> RuntimePolicyReplayResult:
466
+ """Replay historical policy events through a candidate bundle."""
467
+ from spanforge.ulid import generate as _ulid
468
+
469
+ parsed_bundle = self.validate_bundle(candidate_bundle)
470
+ if parsed_bundle.environment != environment:
471
+ raise ValueError("candidate_bundle.environment must match replay environment")
472
+
473
+ simulations: list[RuntimePolicySimulationResult] = []
474
+ blocked_count = 0
475
+ review_count = 0
476
+ changed_count = 0
477
+ for event in events:
478
+ parsed_event = self._validated_historical_event(event, environment=environment)
479
+ trace_id = str(parsed_event["trace_id"])
480
+ production_decision = self._decision_from_event(event)
481
+ simulation = self.simulate(
482
+ environment=environment,
483
+ trace_id=trace_id,
484
+ service=str(parsed_event["service"]),
485
+ control=str(parsed_event["control"]),
486
+ simulated_at=replayed_at,
487
+ candidate_bundle=parsed_bundle,
488
+ observed_value=self._optional_float(parsed_event.get("observed_value")),
489
+ metadata=dict(parsed_event.get("metadata", {})),
490
+ production_decision=production_decision,
491
+ )
492
+ simulations.append(simulation)
493
+ if simulation.candidate_decision.action == "block":
494
+ blocked_count += 1
495
+ if simulation.candidate_decision.action == "human_review":
496
+ review_count += 1
497
+ if simulation.changed:
498
+ changed_count += 1
499
+
500
+ result = RuntimePolicyReplayResult(
501
+ replay_id=_ulid(),
502
+ environment=environment,
503
+ policy_id=parsed_bundle.policy_id,
504
+ policy_version=parsed_bundle.version,
505
+ replayed_at=replayed_at,
506
+ event_count=len(events),
507
+ changed_count=changed_count,
508
+ blocked_count=blocked_count,
509
+ review_count=review_count,
510
+ simulations=simulations,
511
+ )
512
+ with self._lock:
513
+ self._replay_records[result.replay_id] = result
514
+ self._replays_emitted += 1
515
+ self._emit_policy_replay(result)
516
+ return result
517
+
518
+ def compare_policies(
519
+ self,
520
+ *,
521
+ environment: str,
522
+ compared_at: str,
523
+ events: list[dict[str, Any]],
524
+ baseline_bundle: RuntimePolicyBundle | dict[str, Any],
525
+ candidate_bundle: RuntimePolicyBundle | dict[str, Any],
526
+ ) -> RuntimePolicyComparisonResult:
527
+ """Compare baseline and candidate outcomes across historical events."""
528
+ from spanforge.ulid import generate as _ulid
529
+
530
+ baseline = self.validate_bundle(baseline_bundle)
531
+ candidate = self.validate_bundle(candidate_bundle)
532
+ if baseline.environment != environment or candidate.environment != environment:
533
+ raise ValueError("baseline and candidate bundle environments must match comparison environment")
534
+
535
+ changed_count = 0
536
+ action_changes: dict[str, int] = {}
537
+ for event in events:
538
+ parsed_event = self._validated_historical_event(event, environment=environment)
539
+ baseline_decision = self._evaluate_bundle(
540
+ bundle=baseline,
541
+ environment=environment,
542
+ service=str(parsed_event["service"]),
543
+ control=str(parsed_event["control"]),
544
+ evaluated_at=compared_at,
545
+ observed_value=self._optional_float(parsed_event.get("observed_value")),
546
+ metadata=dict(parsed_event.get("metadata", {})),
547
+ )
548
+ candidate_decision = self._evaluate_bundle(
549
+ bundle=candidate,
550
+ environment=environment,
551
+ service=str(parsed_event["service"]),
552
+ control=str(parsed_event["control"]),
553
+ evaluated_at=compared_at,
554
+ observed_value=self._optional_float(parsed_event.get("observed_value")),
555
+ metadata=dict(parsed_event.get("metadata", {})),
556
+ )
557
+ if self._decision_changed(baseline_decision, candidate_decision):
558
+ changed_count += 1
559
+ key = f"{baseline_decision.action}->{candidate_decision.action}"
560
+ action_changes[key] = action_changes.get(key, 0) + 1
561
+
562
+ result = RuntimePolicyComparisonResult(
563
+ comparison_id=_ulid(),
564
+ environment=environment,
565
+ baseline_policy_id=baseline.policy_id,
566
+ baseline_policy_version=baseline.version,
567
+ candidate_policy_id=candidate.policy_id,
568
+ candidate_policy_version=candidate.version,
569
+ compared_at=compared_at,
570
+ event_count=len(events),
571
+ changed_count=changed_count,
572
+ action_changes=action_changes,
573
+ )
574
+ self._emit_policy_comparison(result)
575
+ return result
576
+
577
+ def record_review(
578
+ self,
579
+ *,
580
+ decision_id: str,
581
+ trace_id: str,
582
+ classification: str,
583
+ recorded_at: str,
584
+ notes: str = "",
585
+ ) -> RuntimePolicyReviewRecord:
586
+ """Record a basic false-positive review for a policy decision."""
587
+ from spanforge.ulid import generate as _ulid
588
+
589
+ decision = self.get_decision(decision_id)
590
+ if decision is None:
591
+ raise KeyError(decision_id)
592
+ review = RuntimePolicyReviewRecord(
593
+ review_id=_ulid(),
594
+ decision_id=decision_id,
595
+ trace_id=trace_id,
596
+ environment=decision.environment,
597
+ service=decision.service,
598
+ control=decision.control,
599
+ action=decision.action,
600
+ policy_id=decision.policy_id,
601
+ policy_version=decision.policy_version,
602
+ classification=classification,
603
+ recorded_at=recorded_at,
604
+ notes=notes,
605
+ )
606
+ with self._lock:
607
+ self._review_records[review.review_id] = review
608
+ self._reviews_by_trace.setdefault(trace_id, []).append(review.review_id)
609
+ self._reviews_recorded += 1
610
+ self._emit_policy_review(review)
611
+ return review
612
+
613
+ def list_reviews_for_trace(self, trace_id: str) -> list[RuntimePolicyReviewRecord]:
614
+ """Return review records recorded for a trace."""
615
+ with self._lock:
616
+ ids = list(self._reviews_by_trace.get(trace_id, []))
617
+ return [self._review_records[item] for item in ids if item in self._review_records]
618
+
619
+ def suggest_threshold(
620
+ self,
621
+ *,
622
+ service: str,
623
+ control: str,
624
+ classification: str = "false_positive",
625
+ comparator: str = "lt",
626
+ ) -> float | None:
627
+ """Suggest a threshold from reviewed decisions for one service/control."""
628
+ if comparator not in {"lt", "lte", "gt", "gte"}:
629
+ raise ValueError("comparator must be one of 'lt', 'lte', 'gt', 'gte'")
630
+
631
+ observed_values: list[float] = []
632
+ with self._lock:
633
+ reviews = list(self._review_records.values())
634
+ for review in reviews:
635
+ if review.classification != classification:
636
+ continue
637
+ if review.service != service or review.control != control:
638
+ continue
639
+ decision = self.get_decision(review.decision_id)
640
+ if decision is None or decision.observed_value is None:
641
+ continue
642
+ observed_values.append(decision.observed_value)
643
+
644
+ if not observed_values:
645
+ return None
646
+ if comparator in {"lt", "lte"}:
647
+ return max(observed_values)
648
+ return min(observed_values)
649
+
650
+ def get_decision(self, decision_id: str) -> RuntimePolicyDecision | None:
651
+ """Return a previously emitted policy decision."""
652
+ with self._lock:
653
+ return self._decision_records.get(decision_id)
654
+
655
+ def get_simulation(self, simulation_id: str) -> RuntimePolicySimulationResult | None:
656
+ """Return a recorded simulation result."""
657
+ with self._lock:
658
+ return self._simulation_records.get(simulation_id)
659
+
660
+ def get_replay(self, replay_id: str) -> RuntimePolicyReplayResult | None:
661
+ """Return a recorded replay result."""
662
+ with self._lock:
663
+ return self._replay_records.get(replay_id)
664
+
665
+ def list_decisions_for_trace(self, trace_id: str) -> list[RuntimePolicyDecision]:
666
+ """Return all policy decisions recorded for a trace."""
667
+ with self._lock:
668
+ ids = list(self._decisions_by_trace.get(trace_id, []))
669
+ return [self._decision_records[item] for item in ids if item in self._decision_records]
670
+
671
+ def list_simulations_for_trace(self, trace_id: str) -> list[RuntimePolicySimulationResult]:
672
+ """Return all simulations recorded for a trace."""
673
+ with self._lock:
674
+ ids = list(self._simulations_by_trace.get(trace_id, []))
675
+ return [self._simulation_records[item] for item in ids if item in self._simulation_records]
676
+
677
+ def get_status(self) -> RuntimePolicyStatusInfo:
678
+ """Return policy engine status."""
679
+ with self._lock:
680
+ return RuntimePolicyStatusInfo(
681
+ status="ok",
682
+ loaded_bundles=len(self._bundles),
683
+ active_environments=len(self._active_by_environment),
684
+ decisions_emitted=self._decisions_emitted,
685
+ simulations_emitted=self._simulations_emitted,
686
+ replays_emitted=self._replays_emitted,
687
+ reviews_recorded=self._reviews_recorded,
688
+ )
689
+
690
+ @staticmethod
691
+ def _matching_rule(
692
+ bundle: RuntimePolicyBundle,
693
+ *,
694
+ service: str,
695
+ control: str,
696
+ ) -> RuntimePolicyRule | None:
697
+ for rule in bundle.rules:
698
+ if rule.enabled and rule.service == service and rule.control == control:
699
+ return rule
700
+ return None
701
+
702
+ def _evaluate_bundle(
703
+ self,
704
+ *,
705
+ bundle: RuntimePolicyBundle | None,
706
+ environment: str,
707
+ service: str,
708
+ control: str,
709
+ evaluated_at: str,
710
+ observed_value: float | None,
711
+ metadata: dict[str, Any],
712
+ ) -> RuntimePolicyDecision:
713
+ if bundle is None:
714
+ return self._implicit_allow_decision(
715
+ environment=environment,
716
+ service=service,
717
+ control=control,
718
+ evaluated_at=evaluated_at,
719
+ observed_value=observed_value,
720
+ metadata=metadata,
721
+ )
722
+ rule = self._matching_rule(bundle, service=service, control=control)
723
+ return self._decision_from_rule(
724
+ bundle=bundle,
725
+ rule=rule,
726
+ environment=environment,
727
+ service=service,
728
+ control=control,
729
+ evaluated_at=evaluated_at,
730
+ observed_value=observed_value,
731
+ metadata=metadata,
732
+ )
733
+
734
+ def _implicit_allow_decision(
735
+ self,
736
+ *,
737
+ environment: str,
738
+ service: str,
739
+ control: str,
740
+ evaluated_at: str,
741
+ observed_value: float | None,
742
+ metadata: dict[str, Any],
743
+ ) -> RuntimePolicyDecision:
744
+ from spanforge.ulid import generate as _ulid
745
+
746
+ return RuntimePolicyDecision(
747
+ decision_id=_ulid(),
748
+ policy_id="implicit-default",
749
+ policy_version="none",
750
+ environment=environment,
751
+ service=service,
752
+ control=control,
753
+ action="allow",
754
+ allowed=True,
755
+ evaluated_at=evaluated_at,
756
+ reason=f"no active runtime policy for environment '{environment}'",
757
+ observed_value=observed_value,
758
+ metadata=metadata,
759
+ )
760
+
761
+ def _decision_from_rule(
762
+ self,
763
+ *,
764
+ bundle: RuntimePolicyBundle,
765
+ rule: RuntimePolicyRule | None,
766
+ environment: str,
767
+ service: str,
768
+ control: str,
769
+ evaluated_at: str,
770
+ observed_value: float | None,
771
+ metadata: dict[str, Any],
772
+ ) -> RuntimePolicyDecision:
773
+ from spanforge.ulid import generate as _ulid
774
+
775
+ if rule is None:
776
+ return RuntimePolicyDecision(
777
+ decision_id=_ulid(),
778
+ policy_id=bundle.policy_id,
779
+ policy_version=bundle.version,
780
+ environment=environment,
781
+ service=service,
782
+ control=control,
783
+ action="allow",
784
+ allowed=True,
785
+ evaluated_at=evaluated_at,
786
+ reason=f"no enabled rule matched {service}.{control}",
787
+ observed_value=observed_value,
788
+ metadata=metadata,
789
+ )
790
+
791
+ triggered = self._rule_triggers(rule, observed_value)
792
+ action = rule.action if triggered else "allow"
793
+ reason = (
794
+ rule.rationale
795
+ or f"rule '{rule.rule_id}' triggered {service}.{control}"
796
+ if triggered
797
+ else f"rule '{rule.rule_id}' did not trigger"
798
+ )
799
+ return RuntimePolicyDecision(
800
+ decision_id=_ulid(),
801
+ policy_id=bundle.policy_id,
802
+ policy_version=bundle.version,
803
+ environment=environment,
804
+ service=service,
805
+ control=control,
806
+ action=action,
807
+ allowed=action in {"allow", "allow+log"},
808
+ evaluated_at=evaluated_at,
809
+ reason=reason,
810
+ rule_id=rule.rule_id,
811
+ threshold=rule.threshold,
812
+ observed_value=observed_value,
813
+ metadata=metadata,
814
+ )
815
+
816
+ @staticmethod
817
+ def _decision_from_event(event: dict[str, Any]) -> RuntimePolicyDecision | None:
818
+ action = event.get("production_action")
819
+ if action is None:
820
+ return None
821
+ return RuntimePolicyDecision(
822
+ decision_id=str(event.get("production_decision_id", "production-decision")),
823
+ policy_id=str(event.get("production_policy_id", "production")),
824
+ policy_version=str(event.get("production_policy_version", "current")),
825
+ environment=str(event["environment"]),
826
+ service=str(event["service"]),
827
+ control=str(event["control"]),
828
+ action=str(action),
829
+ allowed=str(action) in {"allow", "allow+log"},
830
+ evaluated_at=str(event.get("evaluated_at", event.get("replayed_at", ""))),
831
+ reason=str(event.get("production_reason", "historical production decision")),
832
+ rule_id=(
833
+ str(event["production_rule_id"])
834
+ if event.get("production_rule_id") is not None
835
+ else None
836
+ ),
837
+ threshold=SFPolicyClient._optional_float(event.get("production_threshold")),
838
+ observed_value=SFPolicyClient._optional_float(event.get("observed_value")),
839
+ metadata=dict(event.get("metadata", {})),
840
+ )
841
+
842
+ @staticmethod
843
+ def _decision_changed(
844
+ baseline: RuntimePolicyDecision | None,
845
+ candidate: RuntimePolicyDecision,
846
+ ) -> bool:
847
+ if baseline is None:
848
+ return True
849
+ return baseline.action != candidate.action or baseline.allowed != candidate.allowed
850
+
851
+ @staticmethod
852
+ def _optional_float(value: Any) -> float | None:
853
+ if value is None:
854
+ return None
855
+ return float(value)
856
+
857
+ @staticmethod
858
+ def _validated_historical_event(event: dict[str, Any], *, environment: str) -> dict[str, Any]:
859
+ if not isinstance(event, dict):
860
+ raise ValueError("historical policy event must be a dict")
861
+ required_fields = ("trace_id", "environment", "service", "control")
862
+ missing = [field for field in required_fields if field not in event]
863
+ if missing:
864
+ raise ValueError(
865
+ "historical policy event is missing required fields: "
866
+ + ", ".join(missing)
867
+ )
868
+ if str(event["environment"]) != environment:
869
+ raise ValueError("historical policy event environment must match requested environment")
870
+ metadata = event.get("metadata", {})
871
+ if metadata is not None and not isinstance(metadata, dict):
872
+ raise ValueError("historical policy event metadata must be a dict when provided")
873
+ observed_value = event.get("observed_value")
874
+ if observed_value is not None:
875
+ numeric = float(observed_value)
876
+ if not (0.0 <= numeric <= 1.0):
877
+ raise ValueError("historical policy event observed_value must be in [0.0, 1.0]")
878
+ return event
879
+
880
+ @staticmethod
881
+ def _rule_triggers(rule: RuntimePolicyRule, observed_value: float | None) -> bool:
882
+ if rule.threshold is None:
883
+ return True
884
+ if observed_value is None:
885
+ return False
886
+ comparator = str(rule.metadata.get("comparator", "lt"))
887
+ if comparator == "lt":
888
+ return observed_value < rule.threshold
889
+ if comparator == "lte":
890
+ return observed_value <= rule.threshold
891
+ if comparator == "gt":
892
+ return observed_value > rule.threshold
893
+ if comparator == "gte":
894
+ return observed_value >= rule.threshold
895
+ if comparator == "eq":
896
+ return observed_value == rule.threshold
897
+ if comparator == "neq":
898
+ return observed_value != rule.threshold
899
+ raise ValueError(f"Unsupported runtime policy comparator: {comparator!r}")
900
+
901
+ def _emit_policy_event(self, payload: dict[str, Any]) -> None:
902
+ from spanforge.sdk import sf_audit
903
+
904
+ sf_audit.append(payload, "spanforge.policy.lifecycle.v1")
905
+
906
+ def _emit_policy_decision(self, *, trace_id: str, decision: RuntimePolicyDecision) -> None:
907
+ from spanforge.sdk import sf_audit
908
+
909
+ payload = {"trace_id": trace_id, **decision.to_dict()}
910
+ sf_audit.append(payload, "spanforge.policy.decision.v1")
911
+
912
+ def _emit_policy_simulation(self, result: RuntimePolicySimulationResult) -> None:
913
+ from spanforge.sdk import sf_audit
914
+
915
+ sf_audit.append(result.to_dict(), "spanforge.policy.simulation.v1")
916
+
917
+ def _emit_policy_replay(self, result: RuntimePolicyReplayResult) -> None:
918
+ from spanforge.sdk import sf_audit
919
+
920
+ sf_audit.append(result.to_dict(), "spanforge.policy.replay.v1")
921
+
922
+ def _emit_policy_comparison(self, result: RuntimePolicyComparisonResult) -> None:
923
+ from spanforge.sdk import sf_audit
924
+
925
+ sf_audit.append(result.to_dict(), "spanforge.policy.comparison.v1")
926
+
927
+ def _emit_policy_review(self, result: RuntimePolicyReviewRecord) -> None:
928
+ from spanforge.sdk import sf_audit
929
+
930
+ sf_audit.append(result.to_dict(), "spanforge.policy.review.v1")