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,494 @@
1
+ """spanforge.namespaces.runtime_governance - GA runtime governance payloads.
2
+
3
+ These payloads freeze the canonical event contracts for the May 2, 2026 GA
4
+ runtime-governance feature set:
5
+
6
+ - explanation
7
+ - grounding
8
+ - lineage
9
+ - scope
10
+ - rbac
11
+
12
+ They intentionally model runtime control decisions rather than generic
13
+ observability spans so Phase 1 service clients can build on stable contracts.
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ from dataclasses import dataclass, field
19
+ from typing import Any
20
+
21
+ __all__ = [
22
+ "ExplanationFactor",
23
+ "ExplanationPayload",
24
+ "GroundingClaim",
25
+ "GroundingPayload",
26
+ "LineagePayload",
27
+ "RBACDecisionPayload",
28
+ "ScopeDecisionPayload",
29
+ ]
30
+
31
+ _VALID_POLICY_ACTIONS = frozenset({"allow", "allow+log", "redact", "block", "human_review"})
32
+ _VALID_DECISION_ACTIONS = frozenset({"allow", "block", "escalate", "human_review", "redact"})
33
+
34
+
35
+ @dataclass
36
+ class ExplanationFactor:
37
+ """One contributing factor for a runtime explanation record."""
38
+
39
+ factor_name: str
40
+ weight: float
41
+ contribution: float
42
+ evidence: str
43
+ confidence: float | None = None
44
+
45
+ def __post_init__(self) -> None:
46
+ if not self.factor_name:
47
+ raise ValueError("ExplanationFactor.factor_name must be non-empty")
48
+ if not (0.0 <= self.weight <= 1.0):
49
+ raise ValueError("ExplanationFactor.weight must be in [0.0, 1.0]")
50
+ if not (-1.0 <= self.contribution <= 1.0):
51
+ raise ValueError("ExplanationFactor.contribution must be in [-1.0, 1.0]")
52
+ if not self.evidence:
53
+ raise ValueError("ExplanationFactor.evidence must be non-empty")
54
+ if self.confidence is not None and not (0.0 <= self.confidence <= 1.0):
55
+ raise ValueError("ExplanationFactor.confidence must be in [0.0, 1.0]")
56
+
57
+ def to_dict(self) -> dict[str, Any]:
58
+ data: dict[str, Any] = {
59
+ "factor_name": self.factor_name,
60
+ "weight": self.weight,
61
+ "contribution": self.contribution,
62
+ "evidence": self.evidence,
63
+ }
64
+ if self.confidence is not None:
65
+ data["confidence"] = self.confidence
66
+ return data
67
+
68
+ @classmethod
69
+ def from_dict(cls, data: dict[str, Any]) -> ExplanationFactor:
70
+ return cls(
71
+ factor_name=data["factor_name"],
72
+ weight=float(data["weight"]),
73
+ contribution=float(data["contribution"]),
74
+ evidence=data["evidence"],
75
+ confidence=float(data["confidence"]) if "confidence" in data else None,
76
+ )
77
+
78
+
79
+ @dataclass
80
+ class ExplanationPayload:
81
+ """Canonical explanation event payload for runtime decisions."""
82
+
83
+ explanation_id: str
84
+ trace_id: str
85
+ decision_id: str
86
+ agent_id: str
87
+ summary: str
88
+ policy_action: str
89
+ generated_at: str
90
+ factors: list[ExplanationFactor] = field(default_factory=list)
91
+ model_id: str | None = None
92
+ confidence: float | None = None
93
+ policy_id: str | None = None
94
+ metadata: dict[str, Any] = field(default_factory=dict)
95
+
96
+ def __post_init__(self) -> None:
97
+ if not self.explanation_id:
98
+ raise ValueError("ExplanationPayload.explanation_id must be non-empty")
99
+ if not self.trace_id:
100
+ raise ValueError("ExplanationPayload.trace_id must be non-empty")
101
+ if not self.decision_id:
102
+ raise ValueError("ExplanationPayload.decision_id must be non-empty")
103
+ if not self.agent_id:
104
+ raise ValueError("ExplanationPayload.agent_id must be non-empty")
105
+ if not self.summary:
106
+ raise ValueError("ExplanationPayload.summary must be non-empty")
107
+ if self.policy_action not in _VALID_POLICY_ACTIONS:
108
+ raise ValueError(
109
+ f"ExplanationPayload.policy_action must be one of {sorted(_VALID_POLICY_ACTIONS)}"
110
+ )
111
+ if not self.generated_at:
112
+ raise ValueError("ExplanationPayload.generated_at must be non-empty")
113
+ if self.confidence is not None and not (0.0 <= self.confidence <= 1.0):
114
+ raise ValueError("ExplanationPayload.confidence must be in [0.0, 1.0]")
115
+
116
+ def to_dict(self) -> dict[str, Any]:
117
+ data: dict[str, Any] = {
118
+ "explanation_id": self.explanation_id,
119
+ "trace_id": self.trace_id,
120
+ "decision_id": self.decision_id,
121
+ "agent_id": self.agent_id,
122
+ "summary": self.summary,
123
+ "policy_action": self.policy_action,
124
+ "generated_at": self.generated_at,
125
+ "factors": [factor.to_dict() for factor in self.factors],
126
+ }
127
+ if self.model_id is not None:
128
+ data["model_id"] = self.model_id
129
+ if self.confidence is not None:
130
+ data["confidence"] = self.confidence
131
+ if self.policy_id is not None:
132
+ data["policy_id"] = self.policy_id
133
+ if self.metadata:
134
+ data["metadata"] = self.metadata
135
+ return data
136
+
137
+ @classmethod
138
+ def from_dict(cls, data: dict[str, Any]) -> ExplanationPayload:
139
+ return cls(
140
+ explanation_id=data["explanation_id"],
141
+ trace_id=data["trace_id"],
142
+ decision_id=data["decision_id"],
143
+ agent_id=data["agent_id"],
144
+ summary=data["summary"],
145
+ policy_action=data["policy_action"],
146
+ generated_at=data["generated_at"],
147
+ factors=[ExplanationFactor.from_dict(item) for item in data.get("factors", [])],
148
+ model_id=data.get("model_id"),
149
+ confidence=float(data["confidence"]) if "confidence" in data else None,
150
+ policy_id=data.get("policy_id"),
151
+ metadata=dict(data.get("metadata", {})),
152
+ )
153
+
154
+
155
+ @dataclass
156
+ class GroundingClaim:
157
+ """One claim-level grounding assessment."""
158
+
159
+ claim_id: str
160
+ claim_text: str
161
+ grounded: bool
162
+ score: float
163
+ source_ids: list[str] = field(default_factory=list)
164
+
165
+ def __post_init__(self) -> None:
166
+ if not self.claim_id:
167
+ raise ValueError("GroundingClaim.claim_id must be non-empty")
168
+ if not self.claim_text:
169
+ raise ValueError("GroundingClaim.claim_text must be non-empty")
170
+ if not (0.0 <= self.score <= 1.0):
171
+ raise ValueError("GroundingClaim.score must be in [0.0, 1.0]")
172
+
173
+ def to_dict(self) -> dict[str, Any]:
174
+ return {
175
+ "claim_id": self.claim_id,
176
+ "claim_text": self.claim_text,
177
+ "grounded": self.grounded,
178
+ "score": self.score,
179
+ "source_ids": list(self.source_ids),
180
+ }
181
+
182
+ @classmethod
183
+ def from_dict(cls, data: dict[str, Any]) -> GroundingClaim:
184
+ return cls(
185
+ claim_id=data["claim_id"],
186
+ claim_text=data["claim_text"],
187
+ grounded=bool(data["grounded"]),
188
+ score=float(data["score"]),
189
+ source_ids=list(data.get("source_ids", [])),
190
+ )
191
+
192
+
193
+ @dataclass
194
+ class GroundingPayload:
195
+ """Canonical grounding event payload for RAG reliability controls."""
196
+
197
+ grounding_id: str
198
+ trace_id: str
199
+ decision_id: str
200
+ session_id: str
201
+ status: str
202
+ average_score: float
203
+ threshold: float
204
+ policy_action: str
205
+ assessed_at: str
206
+ claims: list[GroundingClaim] = field(default_factory=list)
207
+ model_id: str | None = None
208
+ retriever_name: str | None = None
209
+
210
+ def __post_init__(self) -> None:
211
+ if not self.grounding_id:
212
+ raise ValueError("GroundingPayload.grounding_id must be non-empty")
213
+ if not self.trace_id:
214
+ raise ValueError("GroundingPayload.trace_id must be non-empty")
215
+ if not self.decision_id:
216
+ raise ValueError("GroundingPayload.decision_id must be non-empty")
217
+ if not self.session_id:
218
+ raise ValueError("GroundingPayload.session_id must be non-empty")
219
+ if self.status not in {"grounded", "partially_grounded", "ungrounded"}:
220
+ raise ValueError(
221
+ "GroundingPayload.status must be 'grounded', 'partially_grounded', or 'ungrounded'"
222
+ )
223
+ if not (0.0 <= self.average_score <= 1.0):
224
+ raise ValueError("GroundingPayload.average_score must be in [0.0, 1.0]")
225
+ if not (0.0 <= self.threshold <= 1.0):
226
+ raise ValueError("GroundingPayload.threshold must be in [0.0, 1.0]")
227
+ if self.policy_action not in _VALID_POLICY_ACTIONS:
228
+ raise ValueError(
229
+ f"GroundingPayload.policy_action must be one of {sorted(_VALID_POLICY_ACTIONS)}"
230
+ )
231
+ if not self.assessed_at:
232
+ raise ValueError("GroundingPayload.assessed_at must be non-empty")
233
+
234
+ def to_dict(self) -> dict[str, Any]:
235
+ data: dict[str, Any] = {
236
+ "grounding_id": self.grounding_id,
237
+ "trace_id": self.trace_id,
238
+ "decision_id": self.decision_id,
239
+ "session_id": self.session_id,
240
+ "status": self.status,
241
+ "average_score": self.average_score,
242
+ "threshold": self.threshold,
243
+ "policy_action": self.policy_action,
244
+ "assessed_at": self.assessed_at,
245
+ "claims": [claim.to_dict() for claim in self.claims],
246
+ }
247
+ if self.model_id is not None:
248
+ data["model_id"] = self.model_id
249
+ if self.retriever_name is not None:
250
+ data["retriever_name"] = self.retriever_name
251
+ return data
252
+
253
+ @classmethod
254
+ def from_dict(cls, data: dict[str, Any]) -> GroundingPayload:
255
+ return cls(
256
+ grounding_id=data["grounding_id"],
257
+ trace_id=data["trace_id"],
258
+ decision_id=data["decision_id"],
259
+ session_id=data["session_id"],
260
+ status=data["status"],
261
+ average_score=float(data["average_score"]),
262
+ threshold=float(data["threshold"]),
263
+ policy_action=data["policy_action"],
264
+ assessed_at=data["assessed_at"],
265
+ claims=[GroundingClaim.from_dict(item) for item in data.get("claims", [])],
266
+ model_id=data.get("model_id"),
267
+ retriever_name=data.get("retriever_name"),
268
+ )
269
+
270
+
271
+ @dataclass
272
+ class LineagePayload:
273
+ """Canonical provenance payload for data and decision lineage."""
274
+
275
+ lineage_id: str
276
+ trace_id: str
277
+ decision_id: str
278
+ subject_type: str
279
+ subject_id: str
280
+ operation: str
281
+ recorded_at: str
282
+ input_refs: list[str] = field(default_factory=list)
283
+ output_refs: list[str] = field(default_factory=list)
284
+ parent_lineage_ids: list[str] = field(default_factory=list)
285
+ metadata: dict[str, Any] = field(default_factory=dict)
286
+
287
+ def __post_init__(self) -> None:
288
+ if not self.lineage_id:
289
+ raise ValueError("LineagePayload.lineage_id must be non-empty")
290
+ if not self.trace_id:
291
+ raise ValueError("LineagePayload.trace_id must be non-empty")
292
+ if not self.decision_id:
293
+ raise ValueError("LineagePayload.decision_id must be non-empty")
294
+ if not self.subject_type:
295
+ raise ValueError("LineagePayload.subject_type must be non-empty")
296
+ if not self.subject_id:
297
+ raise ValueError("LineagePayload.subject_id must be non-empty")
298
+ if not self.operation:
299
+ raise ValueError("LineagePayload.operation must be non-empty")
300
+ if not self.recorded_at:
301
+ raise ValueError("LineagePayload.recorded_at must be non-empty")
302
+
303
+ def to_dict(self) -> dict[str, Any]:
304
+ data: dict[str, Any] = {
305
+ "lineage_id": self.lineage_id,
306
+ "trace_id": self.trace_id,
307
+ "decision_id": self.decision_id,
308
+ "subject_type": self.subject_type,
309
+ "subject_id": self.subject_id,
310
+ "operation": self.operation,
311
+ "recorded_at": self.recorded_at,
312
+ "input_refs": list(self.input_refs),
313
+ "output_refs": list(self.output_refs),
314
+ "parent_lineage_ids": list(self.parent_lineage_ids),
315
+ }
316
+ if self.metadata:
317
+ data["metadata"] = self.metadata
318
+ return data
319
+
320
+ @classmethod
321
+ def from_dict(cls, data: dict[str, Any]) -> LineagePayload:
322
+ return cls(
323
+ lineage_id=data["lineage_id"],
324
+ trace_id=data["trace_id"],
325
+ decision_id=data["decision_id"],
326
+ subject_type=data["subject_type"],
327
+ subject_id=data["subject_id"],
328
+ operation=data["operation"],
329
+ recorded_at=data["recorded_at"],
330
+ input_refs=list(data.get("input_refs", [])),
331
+ output_refs=list(data.get("output_refs", [])),
332
+ parent_lineage_ids=list(data.get("parent_lineage_ids", [])),
333
+ metadata=dict(data.get("metadata", {})),
334
+ )
335
+
336
+
337
+ @dataclass
338
+ class ScopeDecisionPayload:
339
+ """Canonical runtime payload for agent scope checks."""
340
+
341
+ scope_id: str
342
+ trace_id: str
343
+ agent_id: str
344
+ resource: str
345
+ action_name: str
346
+ allowed: bool
347
+ outcome: str
348
+ reason: str
349
+ checked_at: str
350
+ capability: str | None = None
351
+ policy_id: str | None = None
352
+ policy_action: str | None = None
353
+
354
+ def __post_init__(self) -> None:
355
+ if not self.scope_id:
356
+ raise ValueError("ScopeDecisionPayload.scope_id must be non-empty")
357
+ if not self.trace_id:
358
+ raise ValueError("ScopeDecisionPayload.trace_id must be non-empty")
359
+ if not self.agent_id:
360
+ raise ValueError("ScopeDecisionPayload.agent_id must be non-empty")
361
+ if not self.resource:
362
+ raise ValueError("ScopeDecisionPayload.resource must be non-empty")
363
+ if not self.action_name:
364
+ raise ValueError("ScopeDecisionPayload.action_name must be non-empty")
365
+ if self.outcome not in _VALID_DECISION_ACTIONS:
366
+ raise ValueError(
367
+ f"ScopeDecisionPayload.outcome must be one of {sorted(_VALID_DECISION_ACTIONS)}"
368
+ )
369
+ if not self.reason:
370
+ raise ValueError("ScopeDecisionPayload.reason must be non-empty")
371
+ if not self.checked_at:
372
+ raise ValueError("ScopeDecisionPayload.checked_at must be non-empty")
373
+ if self.policy_action is not None and self.policy_action not in _VALID_POLICY_ACTIONS:
374
+ raise ValueError(
375
+ f"ScopeDecisionPayload.policy_action must be one of {sorted(_VALID_POLICY_ACTIONS)}"
376
+ )
377
+
378
+ def to_dict(self) -> dict[str, Any]:
379
+ data: dict[str, Any] = {
380
+ "scope_id": self.scope_id,
381
+ "trace_id": self.trace_id,
382
+ "agent_id": self.agent_id,
383
+ "resource": self.resource,
384
+ "action_name": self.action_name,
385
+ "allowed": self.allowed,
386
+ "outcome": self.outcome,
387
+ "reason": self.reason,
388
+ "checked_at": self.checked_at,
389
+ }
390
+ if self.capability is not None:
391
+ data["capability"] = self.capability
392
+ if self.policy_id is not None:
393
+ data["policy_id"] = self.policy_id
394
+ if self.policy_action is not None:
395
+ data["policy_action"] = self.policy_action
396
+ return data
397
+
398
+ @classmethod
399
+ def from_dict(cls, data: dict[str, Any]) -> ScopeDecisionPayload:
400
+ return cls(
401
+ scope_id=data["scope_id"],
402
+ trace_id=data["trace_id"],
403
+ agent_id=data["agent_id"],
404
+ resource=data["resource"],
405
+ action_name=data["action_name"],
406
+ allowed=bool(data["allowed"]),
407
+ outcome=data["outcome"],
408
+ reason=data["reason"],
409
+ checked_at=data["checked_at"],
410
+ capability=data.get("capability"),
411
+ policy_id=data.get("policy_id"),
412
+ policy_action=data.get("policy_action"),
413
+ )
414
+
415
+
416
+ @dataclass
417
+ class RBACDecisionPayload:
418
+ """Canonical runtime payload for RBAC authorization checks."""
419
+
420
+ check_id: str
421
+ trace_id: str
422
+ actor_id: str
423
+ resource: str
424
+ action_name: str
425
+ allowed: bool
426
+ outcome: str
427
+ reason: str
428
+ checked_at: str
429
+ required_roles: list[str] = field(default_factory=list)
430
+ effective_roles: list[str] = field(default_factory=list)
431
+ policy_id: str | None = None
432
+ policy_action: str | None = None
433
+
434
+ def __post_init__(self) -> None:
435
+ if not self.check_id:
436
+ raise ValueError("RBACDecisionPayload.check_id must be non-empty")
437
+ if not self.trace_id:
438
+ raise ValueError("RBACDecisionPayload.trace_id must be non-empty")
439
+ if not self.actor_id:
440
+ raise ValueError("RBACDecisionPayload.actor_id must be non-empty")
441
+ if not self.resource:
442
+ raise ValueError("RBACDecisionPayload.resource must be non-empty")
443
+ if not self.action_name:
444
+ raise ValueError("RBACDecisionPayload.action_name must be non-empty")
445
+ if self.outcome not in _VALID_DECISION_ACTIONS:
446
+ raise ValueError(
447
+ f"RBACDecisionPayload.outcome must be one of {sorted(_VALID_DECISION_ACTIONS)}"
448
+ )
449
+ if not self.reason:
450
+ raise ValueError("RBACDecisionPayload.reason must be non-empty")
451
+ if not self.checked_at:
452
+ raise ValueError("RBACDecisionPayload.checked_at must be non-empty")
453
+ if self.policy_action is not None and self.policy_action not in _VALID_POLICY_ACTIONS:
454
+ raise ValueError(
455
+ f"RBACDecisionPayload.policy_action must be one of {sorted(_VALID_POLICY_ACTIONS)}"
456
+ )
457
+
458
+ def to_dict(self) -> dict[str, Any]:
459
+ data: dict[str, Any] = {
460
+ "check_id": self.check_id,
461
+ "trace_id": self.trace_id,
462
+ "actor_id": self.actor_id,
463
+ "resource": self.resource,
464
+ "action_name": self.action_name,
465
+ "allowed": self.allowed,
466
+ "outcome": self.outcome,
467
+ "reason": self.reason,
468
+ "checked_at": self.checked_at,
469
+ "required_roles": list(self.required_roles),
470
+ "effective_roles": list(self.effective_roles),
471
+ }
472
+ if self.policy_id is not None:
473
+ data["policy_id"] = self.policy_id
474
+ if self.policy_action is not None:
475
+ data["policy_action"] = self.policy_action
476
+ return data
477
+
478
+ @classmethod
479
+ def from_dict(cls, data: dict[str, Any]) -> RBACDecisionPayload:
480
+ return cls(
481
+ check_id=data["check_id"],
482
+ trace_id=data["trace_id"],
483
+ actor_id=data["actor_id"],
484
+ resource=data["resource"],
485
+ action_name=data["action_name"],
486
+ allowed=bool(data["allowed"]),
487
+ outcome=data["outcome"],
488
+ reason=data["reason"],
489
+ checked_at=data["checked_at"],
490
+ required_roles=list(data.get("required_roles", [])),
491
+ effective_roles=list(data.get("effective_roles", [])),
492
+ policy_id=data.get("policy_id"),
493
+ policy_action=data.get("policy_action"),
494
+ )