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.
- spanforge/__init__.py +815 -0
- spanforge/_ansi.py +93 -0
- spanforge/_batch_exporter.py +409 -0
- spanforge/_cli.py +2094 -0
- spanforge/_cli_audit.py +639 -0
- spanforge/_cli_compliance.py +711 -0
- spanforge/_cli_cost.py +243 -0
- spanforge/_cli_ops.py +791 -0
- spanforge/_cli_phase11.py +356 -0
- spanforge/_hooks.py +337 -0
- spanforge/_server.py +1708 -0
- spanforge/_span.py +1036 -0
- spanforge/_store.py +288 -0
- spanforge/_stream.py +664 -0
- spanforge/_trace.py +335 -0
- spanforge/_tracer.py +254 -0
- spanforge/actor.py +141 -0
- spanforge/alerts.py +469 -0
- spanforge/auto.py +464 -0
- spanforge/baseline.py +335 -0
- spanforge/cache.py +635 -0
- spanforge/compliance.py +325 -0
- spanforge/config.py +532 -0
- spanforge/consent.py +228 -0
- spanforge/consumer.py +377 -0
- spanforge/core/__init__.py +5 -0
- spanforge/core/compliance_mapping.py +1254 -0
- spanforge/cost.py +600 -0
- spanforge/debug.py +548 -0
- spanforge/deprecations.py +205 -0
- spanforge/drift.py +482 -0
- spanforge/egress.py +58 -0
- spanforge/eval.py +648 -0
- spanforge/event.py +1064 -0
- spanforge/exceptions.py +240 -0
- spanforge/explain.py +178 -0
- spanforge/export/__init__.py +69 -0
- spanforge/export/append_only.py +337 -0
- spanforge/export/cloud.py +357 -0
- spanforge/export/datadog.py +497 -0
- spanforge/export/grafana.py +320 -0
- spanforge/export/jsonl.py +195 -0
- spanforge/export/openinference.py +158 -0
- spanforge/export/otel_bridge.py +294 -0
- spanforge/export/otlp.py +811 -0
- spanforge/export/otlp_bridge.py +233 -0
- spanforge/export/redis_backend.py +282 -0
- spanforge/export/siem_schema.py +98 -0
- spanforge/export/siem_splunk.py +264 -0
- spanforge/export/siem_syslog.py +212 -0
- spanforge/export/webhook.py +299 -0
- spanforge/exporters/__init__.py +30 -0
- spanforge/exporters/console.py +271 -0
- spanforge/exporters/jsonl.py +144 -0
- spanforge/exporters/sqlite.py +142 -0
- spanforge/gate.py +1150 -0
- spanforge/governance.py +181 -0
- spanforge/hitl.py +295 -0
- spanforge/http.py +187 -0
- spanforge/inspect.py +427 -0
- spanforge/integrations/__init__.py +45 -0
- spanforge/integrations/_pricing.py +280 -0
- spanforge/integrations/anthropic.py +388 -0
- spanforge/integrations/azure_openai.py +133 -0
- spanforge/integrations/bedrock.py +292 -0
- spanforge/integrations/crewai.py +251 -0
- spanforge/integrations/gemini.py +351 -0
- spanforge/integrations/groq.py +442 -0
- spanforge/integrations/langchain.py +349 -0
- spanforge/integrations/langgraph.py +306 -0
- spanforge/integrations/llamaindex.py +373 -0
- spanforge/integrations/ollama.py +287 -0
- spanforge/integrations/openai.py +368 -0
- spanforge/integrations/together.py +483 -0
- spanforge/io.py +214 -0
- spanforge/lint.py +322 -0
- spanforge/metrics.py +417 -0
- spanforge/metrics_export.py +343 -0
- spanforge/migrate.py +402 -0
- spanforge/model_registry.py +278 -0
- spanforge/models.py +389 -0
- spanforge/namespaces/__init__.py +254 -0
- spanforge/namespaces/audit.py +256 -0
- spanforge/namespaces/cache.py +237 -0
- spanforge/namespaces/chain.py +77 -0
- spanforge/namespaces/confidence.py +72 -0
- spanforge/namespaces/consent.py +92 -0
- spanforge/namespaces/cost.py +179 -0
- spanforge/namespaces/decision.py +143 -0
- spanforge/namespaces/diff.py +157 -0
- spanforge/namespaces/drift.py +80 -0
- spanforge/namespaces/eval_.py +251 -0
- spanforge/namespaces/feedback.py +241 -0
- spanforge/namespaces/fence.py +193 -0
- spanforge/namespaces/guard.py +105 -0
- spanforge/namespaces/hitl.py +91 -0
- spanforge/namespaces/latency.py +72 -0
- spanforge/namespaces/prompt.py +190 -0
- spanforge/namespaces/redact.py +173 -0
- spanforge/namespaces/retrieval.py +379 -0
- spanforge/namespaces/runtime_governance.py +494 -0
- spanforge/namespaces/template.py +208 -0
- spanforge/namespaces/tool_call.py +77 -0
- spanforge/namespaces/trace.py +1029 -0
- spanforge/normalizer.py +171 -0
- spanforge/plugins.py +82 -0
- spanforge/presidio_backend.py +349 -0
- spanforge/processor.py +258 -0
- spanforge/prompt_registry.py +418 -0
- spanforge/py.typed +0 -0
- spanforge/redact.py +914 -0
- spanforge/regression.py +192 -0
- spanforge/runtime_policy.py +159 -0
- spanforge/sampling.py +511 -0
- spanforge/schema.py +183 -0
- spanforge/schemas/v1.0/schema.json +170 -0
- spanforge/schemas/v2.0/schema.json +536 -0
- spanforge/sdk/__init__.py +625 -0
- spanforge/sdk/_base.py +584 -0
- spanforge/sdk/_base.pyi +71 -0
- spanforge/sdk/_exceptions.py +1096 -0
- spanforge/sdk/_types.py +2184 -0
- spanforge/sdk/alert.py +1514 -0
- spanforge/sdk/alert.pyi +56 -0
- spanforge/sdk/audit.py +1196 -0
- spanforge/sdk/audit.pyi +67 -0
- spanforge/sdk/cec.py +1215 -0
- spanforge/sdk/cec.pyi +37 -0
- spanforge/sdk/config.py +641 -0
- spanforge/sdk/config.pyi +55 -0
- spanforge/sdk/enterprise.py +714 -0
- spanforge/sdk/enterprise.pyi +79 -0
- spanforge/sdk/explain.py +170 -0
- spanforge/sdk/fallback.py +432 -0
- spanforge/sdk/feedback.py +351 -0
- spanforge/sdk/gate.py +874 -0
- spanforge/sdk/gate.pyi +51 -0
- spanforge/sdk/identity.py +2114 -0
- spanforge/sdk/identity.pyi +47 -0
- spanforge/sdk/lineage.py +175 -0
- spanforge/sdk/observe.py +1065 -0
- spanforge/sdk/observe.pyi +50 -0
- spanforge/sdk/operator.py +338 -0
- spanforge/sdk/pii.py +1473 -0
- spanforge/sdk/pii.pyi +119 -0
- spanforge/sdk/pipelines.py +458 -0
- spanforge/sdk/pipelines.pyi +39 -0
- spanforge/sdk/policy.py +930 -0
- spanforge/sdk/rag.py +594 -0
- spanforge/sdk/rbac.py +280 -0
- spanforge/sdk/registry.py +430 -0
- spanforge/sdk/registry.pyi +46 -0
- spanforge/sdk/scope.py +279 -0
- spanforge/sdk/secrets.py +293 -0
- spanforge/sdk/secrets.pyi +25 -0
- spanforge/sdk/security.py +560 -0
- spanforge/sdk/security.pyi +57 -0
- spanforge/sdk/trust.py +472 -0
- spanforge/sdk/trust.pyi +41 -0
- spanforge/secrets.py +799 -0
- spanforge/signing.py +1179 -0
- spanforge/stats.py +100 -0
- spanforge/stream.py +560 -0
- spanforge/testing.py +378 -0
- spanforge/testing_mocks.py +1052 -0
- spanforge/trace.py +199 -0
- spanforge/types.py +696 -0
- spanforge/ulid.py +300 -0
- spanforge/validate.py +379 -0
- spanforge-1.0.0.dist-info/METADATA +1509 -0
- spanforge-1.0.0.dist-info/RECORD +174 -0
- spanforge-1.0.0.dist-info/WHEEL +4 -0
- spanforge-1.0.0.dist-info/entry_points.txt +5 -0
- spanforge-1.0.0.dist-info/licenses/LICENSE +128 -0
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
"""Type stubs for spanforge.sdk.observe (DX-001)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
from spanforge.sdk._base import SFClientConfig, SFServiceClient
|
|
8
|
+
from spanforge.sdk._types import (
|
|
9
|
+
Annotation,
|
|
10
|
+
ExportResult,
|
|
11
|
+
ObserveStatusInfo,
|
|
12
|
+
ReceiverConfig,
|
|
13
|
+
)
|
|
14
|
+
|
|
15
|
+
class SFObserveClient(SFServiceClient):
|
|
16
|
+
def __init__(self, config: SFClientConfig) -> None: ...
|
|
17
|
+
def export_spans(
|
|
18
|
+
self,
|
|
19
|
+
spans: list[dict[str, Any]],
|
|
20
|
+
*,
|
|
21
|
+
receiver_config: ReceiverConfig | None = None,
|
|
22
|
+
) -> ExportResult: ...
|
|
23
|
+
def emit_span(
|
|
24
|
+
self,
|
|
25
|
+
name: str,
|
|
26
|
+
attributes: dict[str, Any],
|
|
27
|
+
*,
|
|
28
|
+
trace_id_hex: str | None = None,
|
|
29
|
+
parent_traceparent: str | None = None,
|
|
30
|
+
) -> dict[str, Any]: ...
|
|
31
|
+
def add_annotation(
|
|
32
|
+
self,
|
|
33
|
+
event_type: str,
|
|
34
|
+
payload: dict[str, Any],
|
|
35
|
+
*,
|
|
36
|
+
project_id: str,
|
|
37
|
+
) -> str: ...
|
|
38
|
+
def get_annotations(
|
|
39
|
+
self,
|
|
40
|
+
event_type: str,
|
|
41
|
+
from_dt: str,
|
|
42
|
+
to_dt: str,
|
|
43
|
+
*,
|
|
44
|
+
project_id: str = "",
|
|
45
|
+
) -> list[Annotation]: ...
|
|
46
|
+
@property
|
|
47
|
+
def healthy(self) -> bool: ...
|
|
48
|
+
@property
|
|
49
|
+
def last_export_at(self) -> str | None: ...
|
|
50
|
+
def get_status(self) -> ObserveStatusInfo: ...
|
|
@@ -0,0 +1,338 @@
|
|
|
1
|
+
"""spanforge.sdk.operator - Operator workflow aggregation for runtime governance."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
from dataclasses import dataclass, field
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import Any
|
|
9
|
+
|
|
10
|
+
from spanforge.sdk._base import SFClientConfig, SFServiceClient
|
|
11
|
+
|
|
12
|
+
__all__ = ["OperatorEvidencePackage", "OperatorWorkflowView", "SFOperatorClient"]
|
|
13
|
+
|
|
14
|
+
_TRACE_AUDIT_SCHEMAS: tuple[str, ...] = (
|
|
15
|
+
"spanforge.policy.decision.v1",
|
|
16
|
+
"spanforge.policy.review.v1",
|
|
17
|
+
"spanforge.explanation.v1",
|
|
18
|
+
"spanforge.grounding.v1",
|
|
19
|
+
"spanforge.scope.v1",
|
|
20
|
+
"spanforge.rbac.v1",
|
|
21
|
+
"spanforge.lineage.v1",
|
|
22
|
+
)
|
|
23
|
+
_OUTCOME_PRIORITY: dict[str, int] = {
|
|
24
|
+
"block": 5,
|
|
25
|
+
"human_review": 4,
|
|
26
|
+
"escalate": 4,
|
|
27
|
+
"redact": 3,
|
|
28
|
+
"allow+log": 2,
|
|
29
|
+
"allow": 1,
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _serialize(value: Any) -> Any:
|
|
34
|
+
if hasattr(value, "to_dict"):
|
|
35
|
+
return value.to_dict()
|
|
36
|
+
return value
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
@dataclass
|
|
40
|
+
class OperatorWorkflowView:
|
|
41
|
+
"""Aggregated operator-facing view of one runtime trace."""
|
|
42
|
+
|
|
43
|
+
trace_id: str
|
|
44
|
+
outcome: str
|
|
45
|
+
summary: str
|
|
46
|
+
policy_decisions: list[Any] = field(default_factory=list)
|
|
47
|
+
explanations: list[Any] = field(default_factory=list)
|
|
48
|
+
grounding_results: list[Any] = field(default_factory=list)
|
|
49
|
+
scope_decisions: list[Any] = field(default_factory=list)
|
|
50
|
+
rbac_decisions: list[Any] = field(default_factory=list)
|
|
51
|
+
lineage_records: list[Any] = field(default_factory=list)
|
|
52
|
+
review_records: list[Any] = field(default_factory=list)
|
|
53
|
+
audit_records: list[dict[str, Any]] = field(default_factory=list)
|
|
54
|
+
timeline: list[dict[str, Any]] = field(default_factory=list)
|
|
55
|
+
|
|
56
|
+
def to_dict(self) -> dict[str, Any]:
|
|
57
|
+
return {
|
|
58
|
+
"trace_id": self.trace_id,
|
|
59
|
+
"outcome": self.outcome,
|
|
60
|
+
"summary": self.summary,
|
|
61
|
+
"policy_decisions": [_serialize(item) for item in self.policy_decisions],
|
|
62
|
+
"explanations": [_serialize(item) for item in self.explanations],
|
|
63
|
+
"grounding_results": [_serialize(item) for item in self.grounding_results],
|
|
64
|
+
"scope_decisions": [_serialize(item) for item in self.scope_decisions],
|
|
65
|
+
"rbac_decisions": [_serialize(item) for item in self.rbac_decisions],
|
|
66
|
+
"lineage_records": [_serialize(item) for item in self.lineage_records],
|
|
67
|
+
"review_records": [_serialize(item) for item in self.review_records],
|
|
68
|
+
"audit_records": list(self.audit_records),
|
|
69
|
+
"timeline": list(self.timeline),
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
@dataclass
|
|
74
|
+
class OperatorEvidencePackage:
|
|
75
|
+
"""Exportable signed evidence package for one operator workflow trace."""
|
|
76
|
+
|
|
77
|
+
package_id: str
|
|
78
|
+
trace_id: str
|
|
79
|
+
generated_at: str
|
|
80
|
+
outcome: str
|
|
81
|
+
summary: str
|
|
82
|
+
exported_records: int
|
|
83
|
+
chain_verification: dict[str, Any]
|
|
84
|
+
workflow: OperatorWorkflowView
|
|
85
|
+
checksum: str
|
|
86
|
+
signature: str
|
|
87
|
+
output_path: str | None = None
|
|
88
|
+
|
|
89
|
+
def to_dict(self) -> dict[str, Any]:
|
|
90
|
+
data = {
|
|
91
|
+
"package_id": self.package_id,
|
|
92
|
+
"trace_id": self.trace_id,
|
|
93
|
+
"generated_at": self.generated_at,
|
|
94
|
+
"outcome": self.outcome,
|
|
95
|
+
"summary": self.summary,
|
|
96
|
+
"exported_records": self.exported_records,
|
|
97
|
+
"chain_verification": dict(self.chain_verification),
|
|
98
|
+
"workflow": self.workflow.to_dict(),
|
|
99
|
+
"checksum": self.checksum,
|
|
100
|
+
"signature": self.signature,
|
|
101
|
+
}
|
|
102
|
+
if self.output_path:
|
|
103
|
+
data["output_path"] = self.output_path
|
|
104
|
+
return data
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
class SFOperatorClient(SFServiceClient):
|
|
108
|
+
"""Aggregate trace-linked governance records into one operator workflow."""
|
|
109
|
+
|
|
110
|
+
def __init__(self, config: SFClientConfig) -> None:
|
|
111
|
+
super().__init__(config, service_name="operator")
|
|
112
|
+
|
|
113
|
+
def inspect_trace(self, trace_id: str) -> OperatorWorkflowView:
|
|
114
|
+
"""Return the operator workflow view for one trace."""
|
|
115
|
+
from spanforge.sdk import (
|
|
116
|
+
sf_audit,
|
|
117
|
+
sf_explain,
|
|
118
|
+
sf_lineage,
|
|
119
|
+
sf_policy,
|
|
120
|
+
sf_rag,
|
|
121
|
+
sf_rbac,
|
|
122
|
+
sf_scope,
|
|
123
|
+
)
|
|
124
|
+
|
|
125
|
+
policy_decisions = sf_policy.list_decisions_for_trace(trace_id)
|
|
126
|
+
explanations = sf_explain.list_for_trace(trace_id)
|
|
127
|
+
grounding_results = sf_rag.list_grounding_for_trace(trace_id)
|
|
128
|
+
scope_decisions = sf_scope.list_for_trace(trace_id)
|
|
129
|
+
rbac_decisions = sf_rbac.list_for_trace(trace_id)
|
|
130
|
+
lineage_records = sf_lineage.list_for_trace(trace_id)
|
|
131
|
+
review_records = sf_policy.list_reviews_for_trace(trace_id)
|
|
132
|
+
audit_records = self._audit_records_for_trace(sf_audit, trace_id)
|
|
133
|
+
|
|
134
|
+
view = OperatorWorkflowView(
|
|
135
|
+
trace_id=trace_id,
|
|
136
|
+
outcome=self._resolve_outcome(
|
|
137
|
+
policy_decisions=policy_decisions,
|
|
138
|
+
explanations=explanations,
|
|
139
|
+
grounding_results=grounding_results,
|
|
140
|
+
scope_decisions=scope_decisions,
|
|
141
|
+
rbac_decisions=rbac_decisions,
|
|
142
|
+
),
|
|
143
|
+
summary="",
|
|
144
|
+
policy_decisions=policy_decisions,
|
|
145
|
+
explanations=explanations,
|
|
146
|
+
grounding_results=grounding_results,
|
|
147
|
+
scope_decisions=scope_decisions,
|
|
148
|
+
rbac_decisions=rbac_decisions,
|
|
149
|
+
lineage_records=lineage_records,
|
|
150
|
+
review_records=review_records,
|
|
151
|
+
audit_records=audit_records,
|
|
152
|
+
timeline=self._timeline(
|
|
153
|
+
policy_decisions=policy_decisions,
|
|
154
|
+
explanations=explanations,
|
|
155
|
+
grounding_results=grounding_results,
|
|
156
|
+
scope_decisions=scope_decisions,
|
|
157
|
+
rbac_decisions=rbac_decisions,
|
|
158
|
+
lineage_records=lineage_records,
|
|
159
|
+
),
|
|
160
|
+
)
|
|
161
|
+
view.summary = self._build_summary(view)
|
|
162
|
+
return view
|
|
163
|
+
|
|
164
|
+
def export_package(
|
|
165
|
+
self,
|
|
166
|
+
trace_id: str,
|
|
167
|
+
*,
|
|
168
|
+
output_path: str | None = None,
|
|
169
|
+
) -> OperatorEvidencePackage:
|
|
170
|
+
"""Build a signed export package for one operator workflow trace."""
|
|
171
|
+
from spanforge.sdk import sf_audit
|
|
172
|
+
|
|
173
|
+
view = self.inspect_trace(trace_id)
|
|
174
|
+
generated_at = self._utc_now()
|
|
175
|
+
package_payload = {
|
|
176
|
+
"trace_id": trace_id,
|
|
177
|
+
"generated_at": generated_at,
|
|
178
|
+
"outcome": view.outcome,
|
|
179
|
+
"summary": view.summary,
|
|
180
|
+
"exported_records": len(view.audit_records),
|
|
181
|
+
"chain_verification": sf_audit.verify_chain(view.audit_records),
|
|
182
|
+
"workflow": view.to_dict(),
|
|
183
|
+
}
|
|
184
|
+
signed = sf_audit.sign(package_payload)
|
|
185
|
+
result = OperatorEvidencePackage(
|
|
186
|
+
package_id=signed.record_id,
|
|
187
|
+
trace_id=trace_id,
|
|
188
|
+
generated_at=generated_at,
|
|
189
|
+
outcome=view.outcome,
|
|
190
|
+
summary=view.summary,
|
|
191
|
+
exported_records=len(view.audit_records),
|
|
192
|
+
chain_verification=package_payload["chain_verification"], # type: ignore[arg-type]
|
|
193
|
+
workflow=view,
|
|
194
|
+
checksum=signed.checksum,
|
|
195
|
+
signature=signed.signature,
|
|
196
|
+
output_path=output_path,
|
|
197
|
+
)
|
|
198
|
+
|
|
199
|
+
if output_path:
|
|
200
|
+
target = Path(output_path)
|
|
201
|
+
target.write_text(json.dumps(result.to_dict(), indent=2), encoding="utf-8")
|
|
202
|
+
|
|
203
|
+
return result
|
|
204
|
+
|
|
205
|
+
def _audit_records_for_trace(self, audit_client: Any, trace_id: str) -> list[dict[str, Any]]:
|
|
206
|
+
seen: set[str] = set()
|
|
207
|
+
records: list[dict[str, Any]] = []
|
|
208
|
+
for schema_key in _TRACE_AUDIT_SCHEMAS:
|
|
209
|
+
for record in audit_client.export(schema_key=schema_key, project_id=self._config.project_id or None):
|
|
210
|
+
if record.get("trace_id") != trace_id:
|
|
211
|
+
continue
|
|
212
|
+
record_id = str(record.get("record_id", ""))
|
|
213
|
+
if record_id in seen:
|
|
214
|
+
continue
|
|
215
|
+
seen.add(record_id)
|
|
216
|
+
records.append(record)
|
|
217
|
+
return sorted(records, key=lambda item: str(item.get("timestamp", "")))
|
|
218
|
+
|
|
219
|
+
def _resolve_outcome(
|
|
220
|
+
self,
|
|
221
|
+
*,
|
|
222
|
+
policy_decisions: list[Any],
|
|
223
|
+
explanations: list[Any],
|
|
224
|
+
grounding_results: list[Any],
|
|
225
|
+
scope_decisions: list[Any],
|
|
226
|
+
rbac_decisions: list[Any],
|
|
227
|
+
) -> str:
|
|
228
|
+
outcomes: list[str] = []
|
|
229
|
+
outcomes.extend(str(getattr(item, "action", "")) for item in policy_decisions)
|
|
230
|
+
outcomes.extend(str(getattr(item, "policy_action", "")) for item in explanations)
|
|
231
|
+
outcomes.extend(str(getattr(item, "policy_action", "")) for item in grounding_results)
|
|
232
|
+
outcomes.extend(str(getattr(item, "outcome", "")) for item in scope_decisions)
|
|
233
|
+
outcomes.extend(str(getattr(item, "outcome", "")) for item in rbac_decisions)
|
|
234
|
+
filtered = [item for item in outcomes if item]
|
|
235
|
+
if not filtered:
|
|
236
|
+
return "allow"
|
|
237
|
+
return max(filtered, key=lambda item: _OUTCOME_PRIORITY.get(item, 0))
|
|
238
|
+
|
|
239
|
+
def _build_summary(self, view: OperatorWorkflowView) -> str:
|
|
240
|
+
clauses: list[str] = []
|
|
241
|
+
if view.outcome == "block":
|
|
242
|
+
clauses.append("Blocked by runtime governance controls.")
|
|
243
|
+
elif view.outcome in {"human_review", "escalate"}:
|
|
244
|
+
clauses.append("Escalated for human review.")
|
|
245
|
+
elif view.outcome == "redact":
|
|
246
|
+
clauses.append("Allowed with redaction requirements.")
|
|
247
|
+
elif view.outcome == "allow+log":
|
|
248
|
+
clauses.append("Allowed with signed audit logging.")
|
|
249
|
+
else:
|
|
250
|
+
clauses.append("Allowed by runtime governance policy.")
|
|
251
|
+
|
|
252
|
+
primary = self._primary_policy_decision(view.policy_decisions)
|
|
253
|
+
if primary is not None:
|
|
254
|
+
clauses.append(str(getattr(primary, "reason", "")))
|
|
255
|
+
|
|
256
|
+
scope_violations = [item for item in view.scope_decisions if not bool(getattr(item, "allowed", True))]
|
|
257
|
+
if scope_violations:
|
|
258
|
+
clauses.append(str(getattr(scope_violations[-1], "reason", "")))
|
|
259
|
+
|
|
260
|
+
rbac_violations = [item for item in view.rbac_decisions if not bool(getattr(item, "allowed", True))]
|
|
261
|
+
if rbac_violations:
|
|
262
|
+
clauses.append(str(getattr(rbac_violations[-1], "reason", "")))
|
|
263
|
+
|
|
264
|
+
grounding = self._latest(view.grounding_results, "assessed_at")
|
|
265
|
+
if grounding is not None:
|
|
266
|
+
clauses.append(
|
|
267
|
+
"Grounding "
|
|
268
|
+
f"{getattr(grounding, 'status', 'unknown')} "
|
|
269
|
+
f"(avg={getattr(grounding, 'average_score', 0.0):.2f}, "
|
|
270
|
+
f"threshold={getattr(grounding, 'threshold', 0.0):.2f})."
|
|
271
|
+
)
|
|
272
|
+
|
|
273
|
+
explanation = self._latest(view.explanations, "generated_at")
|
|
274
|
+
if explanation is not None and getattr(explanation, "summary", ""): # type: ignore[arg-type]
|
|
275
|
+
clauses.append(f"Explanation: {explanation.summary}")
|
|
276
|
+
|
|
277
|
+
lineage = self._latest(view.lineage_records, "recorded_at")
|
|
278
|
+
if lineage is not None:
|
|
279
|
+
clauses.append(
|
|
280
|
+
"Lineage captured for "
|
|
281
|
+
f"{getattr(lineage, 'subject_type', 'subject')}:{getattr(lineage, 'subject_id', '')}."
|
|
282
|
+
)
|
|
283
|
+
|
|
284
|
+
return " ".join(part.strip() for part in clauses if part and str(part).strip())
|
|
285
|
+
|
|
286
|
+
def _timeline(
|
|
287
|
+
self,
|
|
288
|
+
*,
|
|
289
|
+
policy_decisions: list[Any],
|
|
290
|
+
explanations: list[Any],
|
|
291
|
+
grounding_results: list[Any],
|
|
292
|
+
scope_decisions: list[Any],
|
|
293
|
+
rbac_decisions: list[Any],
|
|
294
|
+
lineage_records: list[Any],
|
|
295
|
+
) -> list[dict[str, Any]]:
|
|
296
|
+
items: list[dict[str, Any]] = []
|
|
297
|
+
items.extend(self._timeline_items(policy_decisions, category="policy", ts_field="evaluated_at"))
|
|
298
|
+
items.extend(self._timeline_items(explanations, category="explanation", ts_field="generated_at"))
|
|
299
|
+
items.extend(self._timeline_items(grounding_results, category="grounding", ts_field="assessed_at"))
|
|
300
|
+
items.extend(self._timeline_items(scope_decisions, category="scope", ts_field="checked_at"))
|
|
301
|
+
items.extend(self._timeline_items(rbac_decisions, category="rbac", ts_field="checked_at"))
|
|
302
|
+
items.extend(self._timeline_items(lineage_records, category="lineage", ts_field="recorded_at"))
|
|
303
|
+
return sorted(items, key=lambda item: str(item.get("timestamp", "")))
|
|
304
|
+
|
|
305
|
+
def _timeline_items(self, records: list[Any], *, category: str, ts_field: str) -> list[dict[str, Any]]:
|
|
306
|
+
items: list[dict[str, Any]] = []
|
|
307
|
+
for record in records:
|
|
308
|
+
items.append(
|
|
309
|
+
{
|
|
310
|
+
"category": category,
|
|
311
|
+
"timestamp": str(getattr(record, ts_field, "")),
|
|
312
|
+
"record": _serialize(record),
|
|
313
|
+
}
|
|
314
|
+
)
|
|
315
|
+
return items
|
|
316
|
+
|
|
317
|
+
@staticmethod
|
|
318
|
+
def _latest(records: list[Any], field_name: str) -> Any | None:
|
|
319
|
+
if not records:
|
|
320
|
+
return None
|
|
321
|
+
return max(records, key=lambda item: str(getattr(item, field_name, "")))
|
|
322
|
+
|
|
323
|
+
def _primary_policy_decision(self, records: list[Any]) -> Any | None:
|
|
324
|
+
if not records:
|
|
325
|
+
return None
|
|
326
|
+
return max(
|
|
327
|
+
records,
|
|
328
|
+
key=lambda item: (
|
|
329
|
+
_OUTCOME_PRIORITY.get(str(getattr(item, "action", "")), 0),
|
|
330
|
+
str(getattr(item, "evaluated_at", "")),
|
|
331
|
+
),
|
|
332
|
+
)
|
|
333
|
+
|
|
334
|
+
@staticmethod
|
|
335
|
+
def _utc_now() -> str:
|
|
336
|
+
from datetime import datetime, timezone
|
|
337
|
+
|
|
338
|
+
return datetime.now(tz=timezone.utc).isoformat(timespec="microseconds").replace("+00:00", "Z")
|