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,1254 @@
1
+ """spanforge.core.compliance_mapping — Compliance evidence engine.
2
+
3
+ Maps spanforge telemetry events to regulatory framework clauses and produces
4
+ signed attestation packages suitable for audit submission.
5
+
6
+ Supported frameworks
7
+ --------------------
8
+ * soc2 — SOC 2 Type II (CC series)
9
+ * hipaa — HIPAA Security Rule
10
+ * gdpr — GDPR (EU) 2016/679
11
+ * nist_ai_rmf — NIST AI Risk Management Framework 1.0
12
+ * eu_ai_act — EU AI Act (Annex IV documentation requirements)
13
+ * iso_42001 — ISO/IEC 42001:2023 AI Management System
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ import enum
19
+ import hashlib
20
+ import hmac as _hmac
21
+ import json
22
+ import logging
23
+ import os
24
+ from dataclasses import dataclass, field
25
+ from datetime import datetime, timedelta, timezone
26
+ from typing import Any
27
+
28
+ __all__ = [
29
+ "ClauseStatus",
30
+ "ComplianceAttestation",
31
+ "ComplianceEvidencePackage",
32
+ "ComplianceFramework",
33
+ "ComplianceMappingEngine",
34
+ "EvidenceRecord",
35
+ "GapReport",
36
+ "verify_attestation_signature",
37
+ "verify_pdf_attestation",
38
+ ]
39
+
40
+ _log = logging.getLogger("spanforge.core.compliance_mapping")
41
+
42
+ # Fallback signing key used when SPANFORGE_SIGNING_KEY is absent. Only
43
+ # safe for development / CI — never use in production.
44
+ _INSECURE_DEFAULT_KEY: str = "spanforge-insecure-default-do-not-use-in-production"
45
+
46
+ # ---------------------------------------------------------------------------
47
+ # Framework enum
48
+ # ---------------------------------------------------------------------------
49
+
50
+
51
+ class ComplianceFramework(enum.Enum):
52
+ """Supported regulatory frameworks."""
53
+
54
+ SOC2 = "SOC 2 Type II"
55
+ HIPAA = "HIPAA"
56
+ GDPR = "GDPR"
57
+ NIST_AI_RMF = "NIST AI RMF"
58
+ EU_AI_ACT = "EU AI Act"
59
+ ISO_42001 = "ISO/IEC 42001"
60
+
61
+
62
+ # Maps enum value strings and slug strings to _FRAMEWORK_CLAUSES keys
63
+ _FRAMEWORK_KEY_MAP: dict[str, str] = {
64
+ # enum values
65
+ "soc 2 type ii": "soc2",
66
+ "hipaa": "hipaa",
67
+ "gdpr": "gdpr",
68
+ "nist ai rmf": "nist_ai_rmf",
69
+ "eu ai act": "eu_ai_act",
70
+ "iso/iec 42001": "iso_42001",
71
+ # slugs (already match keys, but listed for completeness)
72
+ "soc2": "soc2",
73
+ "nist_ai_rmf": "nist_ai_rmf",
74
+ "eu_ai_act": "eu_ai_act",
75
+ "iso_42001": "iso_42001",
76
+ }
77
+
78
+
79
+ # ---------------------------------------------------------------------------
80
+ # Framework → clause definitions
81
+ # ---------------------------------------------------------------------------
82
+
83
+ # Each clause maps to a list of event-type prefixes that provide evidence.
84
+ _FRAMEWORK_CLAUSES: dict[str, dict[str, dict[str, Any]]] = {
85
+ "soc2": {
86
+ "CC6.1": {
87
+ "title": "Logical and Physical Access Controls — access management",
88
+ "event_prefixes": ["llm.audit.", "llm.trace.", "model_registry."],
89
+ "description": "Events demonstrating actor-based access controls, audit trails, and model lifecycle tracking.",
90
+ "min_event_count": 5,
91
+ "time_window_hours": None,
92
+ "remediation_steps": (
93
+ "Tag every LLM call with an actor identity: `spanforge.configure(actor_id='<user-id>')`. "
94
+ "Emit audit records via `sf_audit.record()`. "
95
+ "Register models in the model registry: `sf_model_registry.register()`."
96
+ ),
97
+ },
98
+ "CC6.6": {
99
+ "title": "PII / Sensitive Data Protection",
100
+ "event_prefixes": ["llm.redact."],
101
+ "description": "Evidence of PII detection and redaction before model transmission.",
102
+ "min_event_count": 5,
103
+ "time_window_hours": None,
104
+ "remediation_steps": (
105
+ "Enable PII redaction: `spanforge.configure(redact_pii=True)`. "
106
+ "Ensure `llm.redact.*` events are being emitted on every LLM call that may carry personal data."
107
+ ),
108
+ },
109
+ "CC7.2": {
110
+ "title": "Anomaly and Threat Detection",
111
+ "event_prefixes": ["llm.drift.", "llm.guard."],
112
+ "description": "Drift detection and guard-rail events demonstrating anomaly monitoring.",
113
+ "min_event_count": 5,
114
+ "time_window_hours": None,
115
+ "remediation_steps": (
116
+ "Enable drift monitoring: `spanforge.configure(drift_detection=True)`. "
117
+ "Add policy gates to your pipelines: `sf_gate.configure(policy_file='policy.yaml')`."
118
+ ),
119
+ },
120
+ "CC8.1": {
121
+ "title": "Change Management — schema validation",
122
+ "event_prefixes": ["llm.trace.", "llm.eval."],
123
+ "description": "Schema-validated telemetry providing a tamper-evident event chain.",
124
+ "min_event_count": 5,
125
+ "time_window_hours": None,
126
+ "remediation_steps": (
127
+ "Ensure `spanforge.configure()` is called at startup so trace events are emitted. "
128
+ "Add evaluation tracking: `spanforge.configure(track_eval=True)`. "
129
+ "Set `SPANFORGE_SIGNING_KEY` for tamper-evident chain integrity."
130
+ ),
131
+ },
132
+ "CC9.2": {
133
+ "title": "Risk Mitigation — cost and budget controls",
134
+ "event_prefixes": ["llm.cost."],
135
+ "description": "Cost budget and spend telemetry supporting financial risk controls.",
136
+ "min_event_count": 5,
137
+ "time_window_hours": None,
138
+ "remediation_steps": (
139
+ "Enable cost tracking: `spanforge.configure(track_cost=True)`. "
140
+ "Set budget alerts: `sf_alert.configure(budget_usd=100)`. "
141
+ "Ensure `llm.cost.*` events are emitted per LLM call."
142
+ ),
143
+ },
144
+ },
145
+ "hipaa": {
146
+ "164.312(b)": {
147
+ "title": "Audit Controls",
148
+ "event_prefixes": ["llm.audit.", "llm.trace."],
149
+ "description": "HMAC-signed event chain providing tamper-evident audit record of PHI activity.",
150
+ "min_event_count": 10,
151
+ "time_window_hours": None,
152
+ "remediation_steps": (
153
+ "Call `sf_audit.record()` on every LLM call that touches PHI. "
154
+ "Set `SPANFORGE_SIGNING_KEY` to enable HMAC chain integrity. "
155
+ "Configure a durable exporter: `spanforge.configure(exporter='sqlite')` or `exporter='jsonl'`."
156
+ ),
157
+ },
158
+ "164.312(a)(1)": {
159
+ "title": "Access Control",
160
+ "event_prefixes": ["llm.trace.", "llm.audit."],
161
+ "description": "Actor-tagged events demonstrating user/system access to PHI workloads.",
162
+ "min_event_count": 5,
163
+ "time_window_hours": None,
164
+ "remediation_steps": (
165
+ "Tag all PHI workloads with an actor identity: `sf_identity.bind_actor(user_id='...')`. "
166
+ "Ensure `actor_id` is present on every `llm.trace.*` event."
167
+ ),
168
+ },
169
+ "164.312(e)(2)(ii)": {
170
+ "title": "Encryption and Decryption",
171
+ "event_prefixes": ["llm.redact."],
172
+ "description": "PII redaction events demonstrating PHI de-identification before model use.",
173
+ "min_event_count": 5,
174
+ "time_window_hours": None,
175
+ "remediation_steps": (
176
+ "Enable PII/PHI redaction: `spanforge.configure(redact_pii=True)`. "
177
+ "For HIPAA workloads, verify that Presidio (or the built-in redactor) is active: "
178
+ "`spanforge doctor` will show PII engine status."
179
+ ),
180
+ },
181
+ "164.530(j)": {
182
+ "title": "Documentation and Retention",
183
+ "event_prefixes": ["llm.trace.", "llm.audit.", "llm.cost."],
184
+ "description": "Complete event log supporting required 6-year audit retention.",
185
+ "min_event_count": 10,
186
+ "time_window_hours": 168,
187
+ "remediation_steps": (
188
+ "Configure a durable exporter: `spanforge.configure(exporter='sqlite', endpoint='./spanforge.db')` "
189
+ "or `exporter='jsonl'` for long-term retention. "
190
+ "For 6-year HIPAA retention, forward events to your SIEM or object storage."
191
+ ),
192
+ },
193
+ },
194
+ "gdpr": {
195
+ "Art.30": {
196
+ "title": "Records of Processing Activities",
197
+ "event_prefixes": ["llm.trace.", "llm.cost.", "llm.audit."],
198
+ "description": "Structured event log mapping to Article 30 processing record requirements.",
199
+ "min_event_count": 10,
200
+ "time_window_hours": None,
201
+ "remediation_steps": (
202
+ "Ensure `spanforge.configure()` is called at startup to emit `llm.trace.*` events. "
203
+ "Enable cost tracking to record processing scope. "
204
+ "Set `SPANFORGE_SIGNING_KEY` to produce a tamper-evident Article 30 record."
205
+ ),
206
+ },
207
+ "Art.35": {
208
+ "title": "Data Protection Impact Assessment",
209
+ "event_prefixes": ["llm.redact.", "llm.guard.", "llm.drift."],
210
+ "description": "Redaction, guard-rail, and drift events supporting DPIA risk evidence.",
211
+ "min_event_count": 5,
212
+ "time_window_hours": None,
213
+ "remediation_steps": (
214
+ "Enable PII redaction and drift monitoring: "
215
+ "`spanforge.configure(redact_pii=True, drift_detection=True)`. "
216
+ "Add guard-rail policies: `sf_gate.configure(policy_file='policy.yaml')`."
217
+ ),
218
+ },
219
+ "Art.22": {
220
+ "title": "Automated Individual Decision-Making — consent and oversight",
221
+ "event_prefixes": ["consent.", "hitl."],
222
+ "description": "Consent boundary and human-in-the-loop events demonstrating safeguards for automated decisions affecting individuals.",
223
+ "min_event_count": 5,
224
+ "time_window_hours": None,
225
+ "remediation_steps": (
226
+ "Integrate consent gating before automated decisions: `sf_consent.check(user_id='...')`. "
227
+ "Add human-in-the-loop reviews for high-risk decisions: `sf_hitl.review(trace_id='...')`."
228
+ ),
229
+ },
230
+ "Art.25": {
231
+ "title": "Data Protection by Design and by Default",
232
+ "event_prefixes": ["llm.redact.", "consent."],
233
+ "description": "PII stripping and consent enforcement at instrumentation level demonstrates privacy-by-design.",
234
+ "min_event_count": 5,
235
+ "time_window_hours": None,
236
+ "remediation_steps": (
237
+ "Enable privacy-by-design at the instrumentation layer: `spanforge.configure(redact_pii=True)`. "
238
+ "Enforce consent boundaries on every data subject interaction: `sf_consent.check()`."
239
+ ),
240
+ },
241
+ },
242
+ "nist_ai_rmf": {
243
+ "MAP.1.1": {
244
+ "title": "AI System Documentation",
245
+ "event_prefixes": ["llm.trace.", "llm.eval.", "model_registry.", "explanation."],
246
+ "description": "Trace, evaluation, model registry, and explainability events documenting AI system behaviour.",
247
+ "min_event_count": 5,
248
+ "time_window_hours": None,
249
+ "remediation_steps": (
250
+ "Register your model: `sf_model_registry.register(model_id='...', owner='...', risk_tier='high')`. "
251
+ "Enable eval tracking: `spanforge.configure(track_eval=True)`. "
252
+ "Integrate explainability: `sf_explain.explain(trace_id='...')`."
253
+ ),
254
+ },
255
+ "MEASURE.2.6": {
256
+ "title": "AI Risk Monitoring",
257
+ "event_prefixes": ["llm.drift.", "llm.guard.", "llm.eval."],
258
+ "description": "Drift and guard events demonstrating continuous risk monitoring.",
259
+ "min_event_count": 5,
260
+ "time_window_hours": None,
261
+ "remediation_steps": (
262
+ "Enable drift detection: `spanforge.configure(drift_detection=True)`. "
263
+ "Add policy gate guards to your AI pipelines: `sf_gate.configure(policy_file='policy.yaml')`."
264
+ ),
265
+ },
266
+ "MANAGE.3.2": {
267
+ "title": "Incident Response",
268
+ "event_prefixes": ["llm.guard.", "llm.audit."],
269
+ "description": "Guard and audit events providing evidence of incident detection and response.",
270
+ "min_event_count": 5,
271
+ "time_window_hours": None,
272
+ "remediation_steps": (
273
+ "Configure gate alerts for policy violations: `sf_alert.configure(on_guard_trip=True)`. "
274
+ "Call `sf_audit.record()` on every guard trip to produce an incident audit trail."
275
+ ),
276
+ },
277
+ "GOVERN.1.7": {
278
+ "title": "AI Policies and Processes",
279
+ "event_prefixes": ["llm.audit.", "llm.trace."],
280
+ "description": "Audit chain demonstrating policy enforcement in AI pipelines.",
281
+ "min_event_count": 5,
282
+ "time_window_hours": None,
283
+ "remediation_steps": (
284
+ "Define and enforce AI policies via gate configuration: `sf_gate.configure(policy_file='policy.yaml')`. "
285
+ "Emit audit records on every policy enforcement decision: `sf_audit.record()`."
286
+ ),
287
+ },
288
+ },
289
+ "eu_ai_act": {
290
+ "AnnexIV.1": {
291
+ "title": "General Description of the AI System",
292
+ "event_prefixes": ["llm.trace.", "llm.eval."],
293
+ "description": "Trace and evaluation telemetry documenting system purpose and behaviour.",
294
+ "min_event_count": 5,
295
+ "time_window_hours": None,
296
+ "remediation_steps": (
297
+ "Ensure `spanforge.configure()` is called at startup so `llm.trace.*` events are emitted. "
298
+ "Enable eval tracking: `spanforge.configure(track_eval=True)`. "
299
+ "Register your model with its intended purpose in the model registry."
300
+ ),
301
+ },
302
+ "Art.13": {
303
+ "title": "Transparency — explainability of AI decisions",
304
+ "event_prefixes": ["explanation."],
305
+ "description": "Explainability records demonstrating that high-risk AI decisions are accompanied by human-readable rationale.",
306
+ "min_event_count": 5,
307
+ "time_window_hours": None,
308
+ "remediation_steps": (
309
+ "Integrate explainability for every high-risk AI decision: `sf_explain.explain(trace_id='...')`. "
310
+ "Verify explanation coverage with: `spanforge compliance report --framework eu_ai_act`."
311
+ ),
312
+ },
313
+ "Art.14": {
314
+ "title": "Human Oversight — HITL review and escalation",
315
+ "event_prefixes": ["hitl.", "consent."],
316
+ "description": "Human-in-the-loop review, escalation, and consent events demonstrating mandatory human oversight of high-risk AI.",
317
+ "min_event_count": 5,
318
+ "time_window_hours": None,
319
+ "remediation_steps": (
320
+ "Add HITL review gates for high-risk decisions: `sf_hitl.review(trace_id='...')`. "
321
+ "Enforce data subject consent: `sf_consent.check(user_id='...')`."
322
+ ),
323
+ },
324
+ "AnnexIV.5": {
325
+ "title": "Human Oversight Measures",
326
+ "event_prefixes": ["llm.guard.", "llm.audit.", "hitl."],
327
+ "description": "Guard, audit, and human-in-the-loop events demonstrating human oversight mechanisms.",
328
+ "min_event_count": 5,
329
+ "time_window_hours": None,
330
+ "remediation_steps": (
331
+ "Add gate guards to all high-risk AI pipelines: `sf_gate.configure(policy_file='policy.yaml')`. "
332
+ "Emit audit records on gate decisions. "
333
+ "Add HITL escalation paths: `sf_hitl.review()`."
334
+ ),
335
+ },
336
+ "AnnexIV.6": {
337
+ "title": "Robustness, Accuracy and Cybersecurity",
338
+ "event_prefixes": ["llm.eval.", "llm.drift."],
339
+ "description": "Evaluation and drift telemetry supporting robustness and accuracy evidence.",
340
+ "min_event_count": 5,
341
+ "time_window_hours": None,
342
+ "remediation_steps": (
343
+ "Enable eval tracking: `spanforge.configure(track_eval=True)`. "
344
+ "Enable drift detection: `spanforge.configure(drift_detection=True)`. "
345
+ "Run periodic evaluations and record results via `sf_eval`."
346
+ ),
347
+ },
348
+ },
349
+ "iso_42001": {
350
+ "6.1": {
351
+ "title": "Actions to Address AI Risks and Opportunities",
352
+ "event_prefixes": ["llm.drift.", "llm.guard.", "llm.eval."],
353
+ "description": "Drift, guard, and evaluation events supporting risk treatment records.",
354
+ "min_event_count": 5,
355
+ "time_window_hours": None,
356
+ "remediation_steps": (
357
+ "Enable drift detection and eval tracking: "
358
+ "`spanforge.configure(drift_detection=True, track_eval=True)`. "
359
+ "Add gate risk controls to AI pipelines: `sf_gate.configure(policy_file='policy.yaml')`."
360
+ ),
361
+ },
362
+ "9.1": {
363
+ "title": "Monitoring, Measurement, Analysis and Evaluation",
364
+ "event_prefixes": ["llm.trace.", "llm.eval.", "llm.cost."],
365
+ "description": "Continuous telemetry supporting measurement and evaluation requirements.",
366
+ "min_event_count": 5,
367
+ "time_window_hours": None,
368
+ "remediation_steps": (
369
+ "Ensure continuous trace, eval, and cost events are emitted: "
370
+ "`spanforge.configure(track_eval=True, track_cost=True)`. "
371
+ "Configure a durable exporter so telemetry is not lost on process restart."
372
+ ),
373
+ },
374
+ "10.1": {
375
+ "title": "Nonconformity and Corrective Action",
376
+ "event_prefixes": ["llm.audit.", "llm.guard."],
377
+ "description": "Audit and guard events documenting corrective actions.",
378
+ "min_event_count": 5,
379
+ "time_window_hours": None,
380
+ "remediation_steps": (
381
+ "Call `sf_audit.record()` for every corrective action taken on an AI nonconformity. "
382
+ "Configure gate alerts so guard trips automatically generate audit records."
383
+ ),
384
+ },
385
+ },
386
+ }
387
+
388
+ # Minimum event count to consider a clause "passed" (not just partial)
389
+ _MIN_PASS_THRESHOLD = 5
390
+
391
+
392
+ # ---------------------------------------------------------------------------
393
+ # Data classes
394
+ # ---------------------------------------------------------------------------
395
+
396
+
397
+ class ClauseStatus(enum.Enum):
398
+ """Pass/fail/coverage status for a single compliance clause."""
399
+
400
+ PASS = "pass" # nosec B105
401
+ FAIL = "fail"
402
+ PARTIAL = "partial"
403
+ NOT_APPLICABLE = "not_applicable"
404
+ UNKNOWN = "unknown"
405
+
406
+
407
+ @dataclass
408
+ class EvidenceRecord:
409
+ """Evidence collected for one framework clause."""
410
+
411
+ clause_id: str
412
+ status: ClauseStatus
413
+ evidence_count: int
414
+ audit_ids: list[str]
415
+ summary: str
416
+
417
+
418
+ @dataclass
419
+ class ComplianceAttestation:
420
+ """HMAC-signed attestation package for a model + framework + period."""
421
+
422
+ model_id: str
423
+ framework: str
424
+ period_from: str
425
+ period_to: str
426
+ generated_at: str
427
+ generated_by: str
428
+ clauses: list[EvidenceRecord]
429
+ overall_status: ClauseStatus
430
+ hmac_sig: str
431
+ model_owner: str | None = None
432
+ model_risk_tier: str | None = None
433
+ model_status: str | None = None
434
+ model_warnings: list[str] = field(default_factory=list)
435
+ explanation_coverage_pct: float | None = None
436
+
437
+ @property
438
+ def from_date(self) -> str:
439
+ """Backward-compatible alias for ``period_from``."""
440
+ return self.period_from
441
+
442
+ @property
443
+ def to_date(self) -> str:
444
+ """Backward-compatible alias for ``period_to``."""
445
+ return self.period_to
446
+
447
+ @property
448
+ def timestamp(self) -> str:
449
+ """Backward-compatible alias for ``generated_at``."""
450
+ return self.generated_at
451
+
452
+ @property
453
+ def signature(self) -> str:
454
+ """Backward-compatible alias for ``hmac_sig``."""
455
+ return self.hmac_sig
456
+
457
+ @property
458
+ def clauses_total(self) -> int:
459
+ """Return the total number of evaluated clauses."""
460
+ return len(self.clauses)
461
+
462
+ @property
463
+ def clauses_covered(self) -> int:
464
+ """Return the number of fully covered clauses."""
465
+ return sum(1 for clause in self.clauses if clause.status == ClauseStatus.PASS)
466
+
467
+ @property
468
+ def coverage_pct(self) -> float:
469
+ """Return full-clause coverage as a percentage."""
470
+ if not self.clauses:
471
+ return 0.0
472
+ return round((self.clauses_covered / self.clauses_total) * 100, 1)
473
+
474
+ @property
475
+ def gaps(self) -> list[str]:
476
+ """Return the clause IDs that still fail outright."""
477
+ return [clause.clause_id for clause in self.clauses if clause.status == ClauseStatus.FAIL]
478
+
479
+ @property
480
+ def total_events(self) -> int:
481
+ """Return the total number of evidence events across all clauses."""
482
+ return sum(clause.evidence_count for clause in self.clauses)
483
+
484
+ @property
485
+ def attestation_id(self) -> str:
486
+ """Return a stable identifier for the attestation payload."""
487
+ digest = hashlib.sha256(
488
+ f"{self.framework}|{self.model_id}|{self.period_from}|{self.period_to}|{self.generated_at}".encode()
489
+ ).hexdigest()[:12]
490
+ return f"sfatt_{digest}"
491
+
492
+ def to_json(self) -> str:
493
+ """Return the attestation as a compact JSON string."""
494
+ doc: dict[str, Any] = {
495
+ "model_id": self.model_id,
496
+ "framework": self.framework,
497
+ "period_from": self.period_from,
498
+ "period_to": self.period_to,
499
+ "generated_at": self.generated_at,
500
+ "generated_by": self.generated_by,
501
+ "overall_status": self.overall_status.value,
502
+ "hmac_sig": self.hmac_sig,
503
+ "clauses": [
504
+ {
505
+ "clause_id": r.clause_id,
506
+ "status": r.status.value,
507
+ "evidence_count": r.evidence_count,
508
+ "audit_ids": r.audit_ids[:20], # cap for readability
509
+ "summary": r.summary,
510
+ }
511
+ for r in self.clauses
512
+ ],
513
+ }
514
+ if self.model_owner is not None:
515
+ doc["model_owner"] = self.model_owner
516
+ if self.model_risk_tier is not None:
517
+ doc["model_risk_tier"] = self.model_risk_tier
518
+ if self.model_status is not None:
519
+ doc["model_status"] = self.model_status
520
+ if self.model_warnings:
521
+ doc["model_warnings"] = self.model_warnings
522
+ if self.explanation_coverage_pct is not None:
523
+ doc["explanation_coverage_pct"] = self.explanation_coverage_pct
524
+ return json.dumps(doc, indent=2)
525
+
526
+
527
+ @dataclass
528
+ class GapReport:
529
+ """Summary of compliance gaps found during the analysis."""
530
+
531
+ model_id: str
532
+ framework: str
533
+ period_from: str
534
+ period_to: str
535
+ generated_at: str
536
+ gap_clause_ids: list[str]
537
+ partial_clause_ids: list[str]
538
+
539
+ @property
540
+ def has_gaps(self) -> bool:
541
+ """Return True if any gap clause IDs exist."""
542
+ return bool(self.gap_clause_ids)
543
+
544
+ @property
545
+ def total_issues(self) -> int:
546
+ """Return total number of gap and partial clause issues."""
547
+ return len(self.gap_clause_ids) + len(self.partial_clause_ids)
548
+
549
+
550
+ @dataclass
551
+ class ComplianceEvidencePackage:
552
+ """Full deliverable: attestation + human-readable report + gap analysis + audit exports."""
553
+
554
+ attestation: ComplianceAttestation
555
+ report_text: str
556
+ gap_report: GapReport
557
+ audit_exports: dict[str, list[dict[str, Any]]] = field(default_factory=dict)
558
+
559
+ @property
560
+ def framework(self) -> str:
561
+ """Return the framework for this package."""
562
+ return self.attestation.framework
563
+
564
+ @property
565
+ def model_id(self) -> str:
566
+ """Return the model ID for this package."""
567
+ return self.attestation.model_id
568
+
569
+ @property
570
+ def mappings(self) -> list[EvidenceRecord]:
571
+ """Backward-compatible alias for clause evidence records."""
572
+ return self.attestation.clauses
573
+
574
+ def to_json(self) -> str:
575
+ """Return a signed JSON attestation with HMAC covering canonical bytes.
576
+
577
+ The output includes: framework, model_id, period, generated_at,
578
+ clauses with status/evidence_count/summary, gap_clause_ids,
579
+ overall_status, and hmac_sig.
580
+ """
581
+ att = self.attestation
582
+ doc: dict[str, Any] = {
583
+ "framework": att.framework,
584
+ "model_id": att.model_id,
585
+ "period_from": att.period_from,
586
+ "period_to": att.period_to,
587
+ "generated_at": att.generated_at,
588
+ "generated_by": att.generated_by,
589
+ "overall_status": att.overall_status.value,
590
+ "clauses": [
591
+ {
592
+ "clause_id": r.clause_id,
593
+ "status": r.status.value,
594
+ "evidence_count": r.evidence_count,
595
+ "summary": r.summary,
596
+ }
597
+ for r in att.clauses
598
+ ],
599
+ "gap_clause_ids": self.gap_report.gap_clause_ids,
600
+ "hmac_sig": att.hmac_sig,
601
+ }
602
+ if att.model_owner is not None:
603
+ doc["model_owner"] = att.model_owner
604
+ if att.model_risk_tier is not None:
605
+ doc["model_risk_tier"] = att.model_risk_tier
606
+ if att.model_status is not None:
607
+ doc["model_status"] = att.model_status
608
+ if att.model_warnings:
609
+ doc["model_warnings"] = att.model_warnings
610
+ if att.explanation_coverage_pct is not None:
611
+ doc["explanation_coverage_pct"] = att.explanation_coverage_pct
612
+ return json.dumps(doc, sort_keys=True, separators=(",", ":"))
613
+
614
+ def to_markdown(self) -> str:
615
+ """Return the human-readable Markdown report.
616
+
617
+ This is the same content as ``report_text`` (already Markdown), exposed
618
+ as an explicit method for symmetry with ``to_json()`` and ``to_pdf()``.
619
+ """
620
+ return self.report_text
621
+
622
+ def to_pdf(self, path: str | Any) -> Any:
623
+ """Generate a signed PDF attestation report.
624
+
625
+ Requires ``reportlab``: ``pip install 'spanforge[compliance]'``.
626
+
627
+ Args:
628
+ path: File path for the output PDF.
629
+
630
+ Returns:
631
+ :class:`pathlib.Path` to the written PDF file.
632
+
633
+ Raises:
634
+ ImportError: If ``reportlab`` is not installed.
635
+ """
636
+ from pathlib import Path as _Path
637
+
638
+ try:
639
+ from reportlab.lib.pagesizes import A4
640
+ from reportlab.lib.units import mm
641
+ from reportlab.pdfgen import canvas
642
+ except ImportError:
643
+ raise ImportError(
644
+ "PDF attestation export requires reportlab. "
645
+ "Install it with: pip install 'spanforge[compliance]'"
646
+ ) from None
647
+
648
+ out_path = _Path(path)
649
+ att = self.attestation
650
+
651
+ c = canvas.Canvas(str(out_path), pagesize=A4)
652
+ _, height = A4
653
+ y = height - 40 * mm
654
+
655
+ # Cover page
656
+ c.setFont("Helvetica-Bold", 18)
657
+ c.drawString(30 * mm, y, "SpanForge Compliance Attestation")
658
+ y -= 12 * mm
659
+ c.setFont("Helvetica", 11)
660
+ c.drawString(30 * mm, y, f"Framework: {att.framework.upper()}")
661
+ y -= 7 * mm
662
+ c.drawString(30 * mm, y, f"Model: {att.model_id}")
663
+ y -= 7 * mm
664
+ c.drawString(30 * mm, y, f"Period: {att.period_from} — {att.period_to}")
665
+ y -= 7 * mm
666
+ c.drawString(30 * mm, y, f"Generated: {att.generated_at}")
667
+ y -= 7 * mm
668
+ c.drawString(30 * mm, y, f"Overall Status: {att.overall_status.value.upper()}")
669
+ y -= 14 * mm
670
+
671
+ # Clause table
672
+ c.setFont("Helvetica-Bold", 12)
673
+ c.drawString(30 * mm, y, "Clause Analysis")
674
+ y -= 8 * mm
675
+ c.setFont("Helvetica", 9)
676
+ for rec in att.clauses:
677
+ if y < 30 * mm:
678
+ c.showPage()
679
+ y = height - 30 * mm
680
+ c.setFont("Helvetica", 9)
681
+ icon = {"pass": "PASS", "fail": "FAIL", "partial": "PARTIAL"}.get(rec.status.value, "?") # nosec B105
682
+ c.drawString(30 * mm, y, f"[{icon}] {rec.clause_id}: {rec.summary[:80]}")
683
+ y -= 5 * mm
684
+
685
+ # Gap list
686
+ if self.gap_report.has_gaps:
687
+ y -= 6 * mm
688
+ c.setFont("Helvetica-Bold", 11)
689
+ c.drawString(30 * mm, y, "Gaps Requiring Action")
690
+ y -= 6 * mm
691
+ c.setFont("Helvetica", 9)
692
+ for cid in self.gap_report.gap_clause_ids:
693
+ c.drawString(32 * mm, y, f"- {cid}")
694
+ y -= 5 * mm
695
+
696
+ # HMAC footer
697
+ y -= 10 * mm
698
+ c.setFont("Helvetica", 8)
699
+ c.drawString(30 * mm, y, f"HMAC-SHA256: {att.hmac_sig}")
700
+
701
+ c.save()
702
+
703
+ # Sign the PDF bytes and store in metadata
704
+ pdf_bytes = out_path.read_bytes()
705
+ pdf_hash = hashlib.sha256(pdf_bytes).hexdigest()
706
+ signing_key = os.environ.get("SPANFORGE_SIGNING_KEY", "")
707
+ if not signing_key or signing_key == "spanforge-default":
708
+ _log.warning(
709
+ "SPANFORGE_SIGNING_KEY is not set or uses the insecure default value. "
710
+ "Set a strong secret before generating PDF attestations for production. "
711
+ "Example: export SPANFORGE_SIGNING_KEY=$(openssl rand -hex 32)"
712
+ )
713
+ signing_key = _INSECURE_DEFAULT_KEY
714
+ pdf_hmac = _hmac.new(
715
+ signing_key.encode(),
716
+ pdf_hash.encode(),
717
+ hashlib.sha256,
718
+ ).hexdigest()
719
+
720
+ # Re-open and set metadata
721
+ # Store the HMAC as a sidecar JSON (reportlab doesn't support PDF metadata update easily)
722
+ sidecar = out_path.with_suffix(".pdf.sig")
723
+ sidecar.write_text(
724
+ json.dumps({"SpanForgeHMAC": pdf_hmac, "pdf_sha256": pdf_hash}),
725
+ encoding="utf-8",
726
+ )
727
+
728
+ return out_path
729
+
730
+
731
+ # ---------------------------------------------------------------------------
732
+ # Engine
733
+ # ---------------------------------------------------------------------------
734
+
735
+
736
+ class ComplianceMappingEngine:
737
+ """Map spanforge telemetry events to framework clauses and generate evidence packages."""
738
+
739
+ def generate_evidence_package(
740
+ self,
741
+ model_id: str,
742
+ framework: str,
743
+ from_date: str,
744
+ to_date: str,
745
+ audit_events: list[dict[str, Any]] | None = None,
746
+ ) -> ComplianceEvidencePackage:
747
+ """Analyse *audit_events* and produce a full compliance evidence package.
748
+
749
+ Parameters
750
+ ----------
751
+ model_id:
752
+ The AI model identifier (e.g. ``gpt-4o``).
753
+ framework:
754
+ One of ``soc2``, ``hipaa``, ``gdpr``, ``nist_ai_rmf``,
755
+ ``eu_ai_act``, ``iso_42001``.
756
+ from_date / to_date:
757
+ ISO-8601 date strings defining the audit period.
758
+ audit_events:
759
+ Raw event dicts. If *None* or empty the engine will load from the
760
+ active ``TraceStore`` instead.
761
+ """
762
+ # ------------------------------------------------------------------
763
+ # Resolve framework
764
+ # ------------------------------------------------------------------
765
+ if isinstance(framework, ComplianceFramework):
766
+ raw = framework.value.lower()
767
+ else:
768
+ raw = str(framework).lower().replace("-", "_").replace(" ", "_")
769
+
770
+ framework_key = _FRAMEWORK_KEY_MAP.get(raw, raw)
771
+ if framework_key not in _FRAMEWORK_CLAUSES:
772
+ supported = ", ".join(sorted(_FRAMEWORK_CLAUSES))
773
+ raise ValueError(f"Unknown framework {framework!r}. Supported: {supported}")
774
+
775
+ # ------------------------------------------------------------------
776
+ # Load events
777
+ # ------------------------------------------------------------------
778
+ if not audit_events:
779
+ audit_events = self._load_from_store()
780
+
781
+ # ------------------------------------------------------------------
782
+ # Filter to period
783
+ # ------------------------------------------------------------------
784
+ period_events = self._filter_period(audit_events, from_date, to_date)
785
+
786
+ # ------------------------------------------------------------------
787
+ # Map events to clauses
788
+ # ------------------------------------------------------------------
789
+ clauses_def = _FRAMEWORK_CLAUSES[framework_key]
790
+ evidence_records: list[EvidenceRecord] = []
791
+ audit_exports: dict[str, list[dict[str, Any]]] = {}
792
+
793
+ _now_utc = datetime.now(timezone.utc)
794
+
795
+ for clause_id, clause_info in clauses_def.items():
796
+ prefixes: list[str] = clause_info["event_prefixes"]
797
+ # Per-clause minimum, falling back to global default
798
+ clause_min: int = clause_info.get("min_event_count") or _MIN_PASS_THRESHOLD
799
+ # Optional rolling time window (hours) scoped to this clause
800
+ tw_hours: int | None = clause_info.get("time_window_hours")
801
+
802
+ matching = [
803
+ e
804
+ for e in period_events
805
+ if any(str(e.get("event_type", "")).startswith(p) for p in prefixes)
806
+ ]
807
+
808
+ # Apply clause-level time window filter when defined
809
+ if tw_hours is not None:
810
+ _tw_cutoff = _now_utc - timedelta(hours=tw_hours)
811
+ _cutoff_iso = _tw_cutoff.isoformat()
812
+ matching = [e for e in matching if str(e.get("timestamp", "")) >= _cutoff_iso]
813
+
814
+ model_matching = [e for e in matching if self._event_matches_model(e, model_id)]
815
+ # When a model_id is given, restrict to model-specific events only.
816
+ # Fall back to all matching events only when no model_id is specified.
817
+ effective = model_matching if model_id else matching
818
+
819
+ audit_ids = [str(e.get("event_id", "")) for e in effective[:50]]
820
+ count = len(effective)
821
+ audit_exports[clause_id] = [
822
+ {k: v for k, v in e.items() if k != "signature"} for e in effective[:100]
823
+ ]
824
+
825
+ if count >= clause_min:
826
+ status = ClauseStatus.PASS
827
+ summary = f"{count} events from prefixes {prefixes} satisfy this clause."
828
+ elif count > 0:
829
+ status = ClauseStatus.PARTIAL
830
+ summary = (
831
+ f"Only {count} events found (need ≥{clause_min}). "
832
+ f"Increase instrumentation coverage."
833
+ )
834
+ else:
835
+ status = ClauseStatus.FAIL
836
+ summary = (
837
+ f"No events found matching {prefixes}. "
838
+ f"Add {framework_key.upper()} instrumentation for this clause."
839
+ )
840
+
841
+ evidence_records.append(
842
+ EvidenceRecord(
843
+ clause_id=clause_id,
844
+ status=status,
845
+ evidence_count=count,
846
+ audit_ids=audit_ids,
847
+ summary=summary,
848
+ )
849
+ )
850
+
851
+ # ------------------------------------------------------------------
852
+ # Overall status
853
+ # ------------------------------------------------------------------
854
+ statuses = [r.status for r in evidence_records]
855
+ if all(s == ClauseStatus.PASS for s in statuses):
856
+ overall = ClauseStatus.PASS
857
+ elif any(s == ClauseStatus.FAIL for s in statuses):
858
+ overall = ClauseStatus.FAIL
859
+ else:
860
+ overall = ClauseStatus.PARTIAL
861
+
862
+ # ------------------------------------------------------------------
863
+ # HMAC signature
864
+ # ------------------------------------------------------------------
865
+ generated_at = datetime.now(timezone.utc).isoformat()
866
+ sig_payload = json.dumps(
867
+ {
868
+ "model_id": model_id,
869
+ "framework": framework_key,
870
+ "from": from_date,
871
+ "to": to_date,
872
+ "generated_at": generated_at,
873
+ "clauses": {r.clause_id: r.status.value for r in evidence_records},
874
+ "event_count": len(period_events),
875
+ },
876
+ sort_keys=True,
877
+ )
878
+ signing_key = os.environ.get("SPANFORGE_SIGNING_KEY", "")
879
+ if not signing_key or signing_key == "spanforge-default":
880
+ _log.warning(
881
+ "SPANFORGE_SIGNING_KEY is not set or uses the insecure default value. "
882
+ "Set a strong secret before generating compliance attestations for production. "
883
+ "Example: export SPANFORGE_SIGNING_KEY=$(openssl rand -hex 32)"
884
+ )
885
+ signing_key = _INSECURE_DEFAULT_KEY
886
+ hmac_sig = _hmac.new(
887
+ signing_key.encode(),
888
+ sig_payload.encode(),
889
+ hashlib.sha256,
890
+ ).hexdigest()
891
+
892
+ attestation = ComplianceAttestation(
893
+ model_id=model_id,
894
+ framework=framework_key,
895
+ period_from=from_date,
896
+ period_to=to_date,
897
+ generated_at=generated_at,
898
+ generated_by="spanforge.core.compliance_mapping v1",
899
+ clauses=evidence_records,
900
+ overall_status=overall,
901
+ hmac_sig=hmac_sig,
902
+ )
903
+
904
+ # ------------------------------------------------------------------
905
+ # Model registry enrichment (Fix 3)
906
+ # ------------------------------------------------------------------
907
+ self._enrich_from_model_registry(attestation, model_id)
908
+
909
+ # ------------------------------------------------------------------
910
+ # Explanation coverage metric (Fix 4)
911
+ # ------------------------------------------------------------------
912
+ self._compute_explanation_coverage(attestation, period_events, model_id)
913
+
914
+ # ------------------------------------------------------------------
915
+ # Gap report
916
+ # ------------------------------------------------------------------
917
+ gap_ids = [r.clause_id for r in evidence_records if r.status == ClauseStatus.FAIL]
918
+ partial_ids = [r.clause_id for r in evidence_records if r.status == ClauseStatus.PARTIAL]
919
+ gap_report = GapReport(
920
+ model_id=model_id,
921
+ framework=framework_key,
922
+ period_from=from_date,
923
+ period_to=to_date,
924
+ generated_at=generated_at,
925
+ gap_clause_ids=gap_ids,
926
+ partial_clause_ids=partial_ids,
927
+ )
928
+
929
+ # ------------------------------------------------------------------
930
+ # Human-readable report
931
+ # ------------------------------------------------------------------
932
+ report_text = self._build_report(attestation, gap_report, period_events, clauses_def)
933
+
934
+ return ComplianceEvidencePackage(
935
+ attestation=attestation,
936
+ report_text=report_text,
937
+ gap_report=gap_report,
938
+ audit_exports=audit_exports,
939
+ )
940
+
941
+ # ------------------------------------------------------------------
942
+ # Internal helpers
943
+ # ------------------------------------------------------------------
944
+
945
+ @staticmethod
946
+ def _enrich_from_model_registry(attestation: ComplianceAttestation, model_id: str) -> None:
947
+ """Enrich *attestation* with model registry metadata (owner, risk_tier, status)."""
948
+ if not model_id:
949
+ return
950
+ try:
951
+ from spanforge.model_registry import get_model
952
+
953
+ entry = get_model(model_id)
954
+ if entry is None:
955
+ attestation.model_warnings.append(
956
+ f"Model {model_id!r} is not registered in the model registry. "
957
+ "Register it for full compliance traceability."
958
+ )
959
+ return
960
+
961
+ attestation.model_owner = entry.owner
962
+ attestation.model_risk_tier = entry.risk_tier
963
+ attestation.model_status = entry.status
964
+
965
+ if entry.status == "deprecated":
966
+ attestation.model_warnings.append(
967
+ f"Model {model_id!r} is DEPRECATED in the registry. "
968
+ "Consider migrating to an active model before the next audit period."
969
+ )
970
+ elif entry.status == "retired":
971
+ attestation.model_warnings.append(
972
+ f"Model {model_id!r} is RETIRED in the registry. "
973
+ "Generating a compliance attestation for a retired model is unusual — "
974
+ "verify this is intentional."
975
+ )
976
+ except Exception as _err:
977
+ _log.debug("model registry lookup failed: %s", _err)
978
+
979
+ def _compute_explanation_coverage(
980
+ self,
981
+ attestation: ComplianceAttestation,
982
+ period_events: list[dict[str, Any]],
983
+ model_id: str,
984
+ ) -> None:
985
+ """Compute explanation coverage: % of high-risk decisions with an explanation."""
986
+ # Count decisions (trace spans) for this model in the period
987
+ decision_events = [
988
+ e
989
+ for e in period_events
990
+ if str(e.get("event_type", "")).startswith(("llm.trace.", "hitl."))
991
+ and (
992
+ not model_id
993
+ or (e.get("payload") or {}).get("model", {}).get("name", "").lower()
994
+ == model_id.lower()
995
+ or (e.get("payload") or {}).get("model_id", "").lower() == model_id.lower()
996
+ or str((e.get("payload") or {}).get("model", "")).lower() == model_id.lower()
997
+ )
998
+ ]
999
+ explanation_events = [
1000
+ e for e in period_events if str(e.get("event_type", "")).startswith("explanation.")
1001
+ ]
1002
+
1003
+ decision_count = len(decision_events)
1004
+ explanation_count = len(explanation_events)
1005
+
1006
+ if decision_count > 0:
1007
+ attestation.explanation_coverage_pct = round(
1008
+ min(explanation_count / decision_count * 100, 100.0), 1
1009
+ )
1010
+ else:
1011
+ # No decisions → coverage is N/A; store None to omit from output
1012
+ attestation.explanation_coverage_pct = None
1013
+
1014
+ def _load_from_store(self) -> list[dict[str, Any]]:
1015
+ """Load events from the active TraceStore."""
1016
+ try:
1017
+ from spanforge._store import get_store
1018
+
1019
+ store = get_store()
1020
+ with store._lock:
1021
+ events = [e for evts in store._traces.values() for e in evts]
1022
+ return [
1023
+ {
1024
+ "event_id": getattr(e, "event_id", None),
1025
+ "event_type": getattr(e, "event_type", None),
1026
+ "timestamp": getattr(e, "timestamp", None),
1027
+ "source": getattr(e, "source", None),
1028
+ "trace_id": getattr(e, "trace_id", None),
1029
+ "span_id": getattr(e, "span_id", None),
1030
+ "payload": getattr(e, "payload", {}),
1031
+ "tags": getattr(e, "tags", {}),
1032
+ "signature": getattr(e, "signature", None),
1033
+ }
1034
+ for e in events
1035
+ ]
1036
+ except Exception:
1037
+ return []
1038
+
1039
+ @staticmethod
1040
+ def _filter_period(
1041
+ events: list[dict[str, Any]], from_date: str, to_date: str
1042
+ ) -> list[dict[str, Any]]:
1043
+ """Filter events to the requested date range (inclusive)."""
1044
+
1045
+ def _parse(s: str) -> datetime | None:
1046
+ """Parse a date or datetime string to an aware UTC datetime."""
1047
+ try:
1048
+ dt = datetime.fromisoformat(s.replace("Z", "+00:00"))
1049
+ except ValueError:
1050
+ return None
1051
+ if dt.tzinfo is None:
1052
+ dt = dt.replace(tzinfo=timezone.utc)
1053
+ return dt
1054
+
1055
+ from_dt = _parse(from_date)
1056
+ to_dt = _parse(to_date)
1057
+ if from_dt is None or to_dt is None:
1058
+ raise ValueError(
1059
+ f"Cannot parse date range: from_date={from_date!r}, to_date={to_date!r}. "
1060
+ "Use ISO-8601 format: YYYY-MM-DD or YYYY-MM-DDTHH:MM:SSZ"
1061
+ )
1062
+
1063
+ filtered = []
1064
+ for e in events:
1065
+ ts_raw = e.get("timestamp")
1066
+ if not ts_raw:
1067
+ continue
1068
+ ts = _parse(str(ts_raw))
1069
+ if ts is not None and from_dt <= ts <= to_dt:
1070
+ filtered.append(e)
1071
+
1072
+ return filtered # empty list is correct when no events fall in the period
1073
+
1074
+ @staticmethod
1075
+ def _event_matches_model(event: dict[str, Any], model_id: str) -> bool:
1076
+ """Return True if event is associated with *model_id*."""
1077
+ if not model_id:
1078
+ return True
1079
+ payload = event.get("payload") or {}
1080
+ model_block = payload.get("model") or {}
1081
+ model_name = (
1082
+ model_block.get("name") or payload.get("model_id") or payload.get("model") or ""
1083
+ )
1084
+ return model_id.lower() == str(model_name).lower()
1085
+
1086
+ @staticmethod
1087
+ def _build_report(
1088
+ att: ComplianceAttestation,
1089
+ gap: GapReport,
1090
+ events: list[dict[str, Any]],
1091
+ clauses_def: dict[str, Any],
1092
+ ) -> str:
1093
+ """Build a human-readable markdown-style compliance report."""
1094
+ lines = [
1095
+ "# spanforge Compliance Report",
1096
+ "",
1097
+ "| Field | Value |",
1098
+ "|---------------|-------|",
1099
+ f"| Framework | {att.framework.upper()} |",
1100
+ f"| Model | {att.model_id} |",
1101
+ f"| Period | {att.period_from} → {att.period_to} |",
1102
+ f"| Generated | {att.generated_at} |",
1103
+ f"| Overall | **{att.overall_status.value.upper()}** |",
1104
+ f"| Events in scope | {len(events)} |",
1105
+ f"| HMAC Sig | `{att.hmac_sig[:32]}…` |",
1106
+ ]
1107
+
1108
+ # Model registry metadata
1109
+ if att.model_owner is not None:
1110
+ lines.append(f"| Model Owner | {att.model_owner} |")
1111
+ if att.model_risk_tier is not None:
1112
+ lines.append(f"| Risk Tier | {att.model_risk_tier} |")
1113
+ if att.model_status is not None:
1114
+ lines.append(f"| Model Status | {att.model_status} |")
1115
+
1116
+ # Explanation coverage
1117
+ if att.explanation_coverage_pct is not None:
1118
+ lines.append(f"| Explanation Coverage | {att.explanation_coverage_pct}% |")
1119
+
1120
+ lines.append("")
1121
+
1122
+ # Model warnings
1123
+ if att.model_warnings:
1124
+ lines.append("## ⚠️ Model Registry Warnings")
1125
+ lines.append("")
1126
+ for w in att.model_warnings:
1127
+ lines.append(f"- {w}")
1128
+ lines.append("")
1129
+
1130
+ lines.extend(
1131
+ [
1132
+ "## Clause Analysis",
1133
+ "",
1134
+ ]
1135
+ )
1136
+ for rec in att.clauses:
1137
+ info = clauses_def.get(rec.clause_id, {})
1138
+ icon = {"pass": "✅", "fail": "❌", "partial": "⚠️"}.get(rec.status.value, "❓") # nosec B105
1139
+ lines.append(f"### {icon} {rec.clause_id} — {info.get('title', rec.clause_id)}")
1140
+ lines.append("")
1141
+ lines.append(f"- **Status**: {rec.status.value.upper()}")
1142
+ lines.append(f"- **Evidence events**: {rec.evidence_count}")
1143
+ lines.append(f"- **Summary**: {rec.summary}")
1144
+ lines.append("")
1145
+
1146
+ if gap.has_gaps:
1147
+ lines.append("## ❌ Gaps Requiring Action")
1148
+ lines.append("")
1149
+ for cid in gap.gap_clause_ids:
1150
+ info = clauses_def.get(cid, {})
1151
+ lines.append(
1152
+ f"- **{cid}** — {info.get('title', cid)}: {info.get('description', '')}"
1153
+ )
1154
+ remediation = info.get("remediation_steps")
1155
+ if remediation:
1156
+ lines.append(f" > **Fix**: {remediation}")
1157
+ lines.append("")
1158
+
1159
+ if gap.partial_clause_ids:
1160
+ lines.append("## ⚠️ Partial Coverage")
1161
+ lines.append("")
1162
+ for cid in gap.partial_clause_ids:
1163
+ info = clauses_def.get(cid, {})
1164
+ lines.append(f"- **{cid}** — {info.get('title', cid)}")
1165
+ lines.append("")
1166
+
1167
+ lines.append("---")
1168
+ lines.append(
1169
+ "*Generated by spanforge.core.compliance_mapping. HMAC key: `SPANFORGE_SIGNING_KEY` env var.*"
1170
+ )
1171
+ return "\n".join(lines)
1172
+
1173
+
1174
+ # ---------------------------------------------------------------------------
1175
+ # Attestation verification
1176
+ # ---------------------------------------------------------------------------
1177
+
1178
+
1179
+ def verify_attestation_signature(attestation: ComplianceAttestation) -> bool:
1180
+ """Re-compute the HMAC and compare against the stored signature.
1181
+
1182
+ Returns ``True`` if the attestation has not been tampered with.
1183
+ Note: verification requires the same ``SPANFORGE_SIGNING_KEY`` that was
1184
+ used during generation.
1185
+ """
1186
+ sig_payload = json.dumps(
1187
+ {
1188
+ "model_id": attestation.model_id,
1189
+ "framework": attestation.framework,
1190
+ "from": attestation.period_from,
1191
+ "to": attestation.period_to,
1192
+ "generated_at": attestation.generated_at,
1193
+ "clauses": {r.clause_id: r.status.value for r in attestation.clauses},
1194
+ "event_count": sum(r.evidence_count for r in attestation.clauses),
1195
+ },
1196
+ sort_keys=True,
1197
+ )
1198
+ signing_key = os.environ.get("SPANFORGE_SIGNING_KEY", "")
1199
+ if not signing_key or signing_key == "spanforge-default":
1200
+ _log.warning(
1201
+ "SPANFORGE_SIGNING_KEY is not set or uses the insecure default value. "
1202
+ "Attestation verification requires the same key used at signing time. "
1203
+ "Example: export SPANFORGE_SIGNING_KEY=<your-secret>"
1204
+ )
1205
+ signing_key = _INSECURE_DEFAULT_KEY
1206
+ expected = _hmac.new(
1207
+ signing_key.encode(),
1208
+ sig_payload.encode(),
1209
+ hashlib.sha256,
1210
+ ).hexdigest()
1211
+ return _hmac.compare_digest(attestation.hmac_sig, expected)
1212
+
1213
+
1214
+ def verify_pdf_attestation(path: str | Any, org_secret: str | None = None) -> bool:
1215
+ """Verify that a PDF attestation has not been modified since signing.
1216
+
1217
+ Reads the ``.pdf.sig`` sidecar file to obtain the original HMAC, then
1218
+ re-computes ``HMAC-SHA256(SHA256(pdf_bytes), org_secret)`` and compares.
1219
+
1220
+ Args:
1221
+ path: Path to the PDF file.
1222
+ org_secret: HMAC signing key. If ``None``, reads from
1223
+ ``SPANFORGE_SIGNING_KEY`` env var.
1224
+
1225
+ Returns:
1226
+ ``True`` if the PDF bytes have not been altered since signing.
1227
+ """
1228
+ from pathlib import Path as _Path
1229
+
1230
+ pdf_path = _Path(path)
1231
+ sig_path = pdf_path.with_suffix(".pdf.sig")
1232
+
1233
+ if not sig_path.exists():
1234
+ return False
1235
+
1236
+ sig_data = json.loads(sig_path.read_text(encoding="utf-8"))
1237
+ stored_hmac = sig_data.get("SpanForgeHMAC", "")
1238
+
1239
+ signing_key = org_secret or os.environ.get("SPANFORGE_SIGNING_KEY", "")
1240
+ if not signing_key or signing_key == "spanforge-default":
1241
+ raise ValueError(
1242
+ "SPANFORGE_SIGNING_KEY is not set or uses the insecure default value. "
1243
+ "PDF attestation verification requires the same key used at signing time. "
1244
+ "Example: export SPANFORGE_SIGNING_KEY=<your-secret>"
1245
+ )
1246
+ pdf_bytes = pdf_path.read_bytes()
1247
+ pdf_hash = hashlib.sha256(pdf_bytes).hexdigest()
1248
+ expected_hmac = _hmac.new(
1249
+ signing_key.encode(),
1250
+ pdf_hash.encode(),
1251
+ hashlib.sha256,
1252
+ ).hexdigest()
1253
+
1254
+ return _hmac.compare_digest(stored_hmac, expected_hmac)