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
spanforge/sdk/audit.py ADDED
@@ -0,0 +1,1196 @@
1
+ """spanforge.sdk.audit — SpanForge sf-audit high-level client (Phase 4).
2
+
3
+ Implements the full sf-audit API surface for Phase 4 of the SpanForge roadmap.
4
+ All operations run locally in-process (zero external dependencies) when
5
+ ``config.endpoint`` is empty or when the remote service is unreachable and
6
+ ``local_fallback_enabled`` is ``True``.
7
+
8
+ Architecture
9
+ ------------
10
+ * :meth:`append` is the **single call site** for writing audit records.
11
+ It validates the schema key, signs the record with HMAC-SHA256, writes it
12
+ to the configured backend (local JSONL / BYOS S3/Azure/GCS/R2), and
13
+ additionally writes a T.R.U.S.T. summary record for score-related schemas.
14
+ * :meth:`sign` wraps the low-level ``signing.sign()`` function so callers
15
+ never import from the internal module directly.
16
+ * :meth:`verify_chain` wraps ``signing.verify_chain()``.
17
+ * :meth:`export` performs date-range queries using a SQLite-backed index
18
+ enabling O(log n) lookups without a full file scan.
19
+ * :meth:`get_trust_scorecard` aggregates the T.R.U.S.T. store into
20
+ dimension scores with trend detection.
21
+ * :meth:`generate_article30_record` produces a GDPR Article 30 RoPA document.
22
+ * :meth:`get_status` contributes sf-audit health information to the platform
23
+ status endpoint.
24
+
25
+ Security requirements
26
+ ---------------------
27
+ * HMAC signing keys are **never** logged or included in exception messages.
28
+ * ``org_secret`` is read from ``config.signing_key`` which itself is a plain
29
+ string (not a ``SecretStr``) — callers must ensure it is not logged.
30
+ * SQLite database files are stored under the system temp directory by default
31
+ and are cleaned up on close when ``persist_index=False``.
32
+ * Thread-safety: all in-memory stores and the SQLite connection use locks.
33
+ * BYOS credentials are injected via ``SFClientConfig`` and never echoed.
34
+
35
+ Local-mode feature parity
36
+ --------------------------
37
+ * :meth:`append` — schema validation + HMAC chain + SQLite index
38
+ * :meth:`sign` — raw-dict signing (AUD-003)
39
+ * :meth:`verify_chain` — full chain verification (AUD-004)
40
+ * :meth:`export` — date-range query (AUD-005)
41
+ * :meth:`get_trust_scorecard` — T.R.U.S.T. scorecard (AUD-031)
42
+ * :meth:`generate_article30_record` — GDPR RoPA (AUD-042)
43
+ * :meth:`get_status` — health check
44
+
45
+ BYOS providers supported (AUD-011)
46
+ ------------------------------------
47
+ ``[audit.byos] provider = "s3" | "azure" | "gcs" | "r2"``
48
+
49
+ The BYOS backend is selected at construction time from:
50
+ ``SPANFORGE_AUDIT_BYOS_PROVIDER`` env var → ``config.byos_provider`` → ``None`` (local)
51
+ """
52
+
53
+ from __future__ import annotations
54
+
55
+ import contextlib
56
+ import hashlib
57
+ import hmac as _hmac
58
+ import json
59
+ import logging
60
+ import os
61
+ import sqlite3
62
+ import tempfile
63
+ import threading
64
+ import uuid
65
+ from dataclasses import dataclass
66
+ from datetime import datetime, timezone
67
+ from pathlib import Path
68
+ from typing import Any
69
+
70
+ from spanforge.sdk._base import SFClientConfig, SFServiceClient
71
+ from spanforge.sdk._exceptions import (
72
+ SFAuditAppendError,
73
+ SFAuditError, # noqa: F401 (re-exported for callers)
74
+ SFAuditQueryError,
75
+ SFAuditSchemaError,
76
+ )
77
+ from spanforge.sdk._types import (
78
+ Article30Record,
79
+ AuditAppendResult,
80
+ AuditStatusInfo,
81
+ SignedRecord,
82
+ TrustDimension,
83
+ TrustScorecard,
84
+ )
85
+
86
+ __all__ = ["SFAuditClient"]
87
+
88
+ _log = logging.getLogger(__name__)
89
+
90
+ # ---------------------------------------------------------------------------
91
+ # Known schema keys (AUD-002)
92
+ # ---------------------------------------------------------------------------
93
+
94
+ KNOWN_SCHEMA_KEYS: frozenset[str] = frozenset(
95
+ {
96
+ "halluccheck.score.v1",
97
+ "halluccheck.bias.v1",
98
+ "halluccheck.prri.v1",
99
+ "halluccheck.drift.v1",
100
+ "halluccheck.opa.v1",
101
+ "halluccheck.pii.v1",
102
+ "halluccheck.secrets.v1",
103
+ "halluccheck.gate.v1",
104
+ "halluccheck.auth.v1",
105
+ "halluccheck.benchmark_run.v1",
106
+ "halluccheck.benchmark_version.v1",
107
+ "spanforge.auth.v1",
108
+ "spanforge.consent.v1",
109
+ "spanforge.explanation.v1",
110
+ "spanforge.grounding.v1",
111
+ "spanforge.lineage.v1",
112
+ "spanforge.policy.comparison.v1",
113
+ "spanforge.policy.decision.v1",
114
+ "spanforge.policy.lifecycle.v1",
115
+ "spanforge.policy.replay.v1",
116
+ "spanforge.policy.review.v1",
117
+ "spanforge.policy.simulation.v1",
118
+ "spanforge.rbac.v1",
119
+ "spanforge.scope.v1",
120
+ # Internal T.R.U.S.T. store schema
121
+ "spanforge.trust.v1",
122
+ }
123
+ )
124
+
125
+ # Schema keys that feed the T.R.U.S.T. store (AUD-030)
126
+ _TRUST_FEED_SCHEMAS: dict[str, str] = {
127
+ "halluccheck.score.v1": "hallucination",
128
+ "halluccheck.pii.v1": "pii_hygiene",
129
+ "halluccheck.secrets.v1": "secrets_hygiene",
130
+ "halluccheck.gate.v1": "gate_pass_rate",
131
+ "halluccheck.bias.v1": "hallucination",
132
+ "halluccheck.drift.v1": "hallucination",
133
+ "halluccheck.opa.v1": "compliance_posture",
134
+ "halluccheck.prri.v1": "compliance_posture",
135
+ }
136
+
137
+ # Default retention years (AUD-010)
138
+ _DEFAULT_RETENTION_YEARS: int = 7
139
+
140
+ # ---------------------------------------------------------------------------
141
+ # BYOS backend detection
142
+ # ---------------------------------------------------------------------------
143
+
144
+ _BYOS_PROVIDERS: frozenset[str] = frozenset({"s3", "azure", "gcs", "r2"})
145
+
146
+
147
+ def _detect_byos_provider() -> str | None:
148
+ """Return the BYOS provider name from env var or ``None`` for local mode."""
149
+ raw = os.environ.get("SPANFORGE_AUDIT_BYOS_PROVIDER", "").strip().lower()
150
+ return raw if raw in _BYOS_PROVIDERS else None
151
+
152
+
153
+ # ---------------------------------------------------------------------------
154
+ # SQLite index schema (AUD-020)
155
+ # ---------------------------------------------------------------------------
156
+
157
+ _INDEX_DDL = """\
158
+ CREATE TABLE IF NOT EXISTS audit_index (
159
+ record_id TEXT NOT NULL PRIMARY KEY,
160
+ schema_key TEXT NOT NULL,
161
+ project_id TEXT NOT NULL DEFAULT '',
162
+ ts TEXT NOT NULL,
163
+ file_path TEXT NOT NULL DEFAULT '',
164
+ byte_offset INTEGER NOT NULL DEFAULT 0
165
+ );
166
+ CREATE INDEX IF NOT EXISTS idx_schema_ts ON audit_index (schema_key, ts);
167
+ CREATE INDEX IF NOT EXISTS idx_project_ts ON audit_index (project_id, ts);
168
+ CREATE INDEX IF NOT EXISTS idx_schema_proj ON audit_index (schema_key, project_id, ts);
169
+ """
170
+
171
+
172
+ # ---------------------------------------------------------------------------
173
+ # Internal record store (in-memory + JSONL persistence)
174
+ # ---------------------------------------------------------------------------
175
+
176
+
177
+ @dataclass
178
+ class _AuditRecord:
179
+ """Internal representation of a stored audit record."""
180
+
181
+ record_id: str
182
+ schema_key: str
183
+ project_id: str
184
+ timestamp: str
185
+ hmac: str
186
+ chain_position: int
187
+ payload: dict[str, Any]
188
+
189
+
190
+ class _LocalAuditStore:
191
+ """Thread-safe in-memory + SQLite audit record store."""
192
+
193
+ def __init__(self, db_path: str) -> None:
194
+ self._lock = threading.Lock()
195
+ self._records: list[_AuditRecord] = []
196
+ self._trust_records: list[dict[str, Any]] = []
197
+ self._db_path = db_path
198
+ self._db: sqlite3.Connection | None = None
199
+ self._init_db()
200
+
201
+ def _init_db(self) -> None:
202
+ """Initialise the SQLite index database."""
203
+ try:
204
+ self._db = sqlite3.connect(self._db_path, check_same_thread=False)
205
+ self._db.executescript(_INDEX_DDL)
206
+ self._db.commit()
207
+ except Exception as exc: # pragma: no cover
208
+ _log.warning("sf-audit: SQLite index init failed: %s", exc)
209
+ self._db = None
210
+
211
+ def append(self, rec: _AuditRecord) -> None:
212
+ with self._lock:
213
+ self._records.append(rec)
214
+ self._index_record(rec)
215
+
216
+ def _index_record(self, rec: _AuditRecord) -> None:
217
+ if self._db is None:
218
+ return
219
+ try:
220
+ self._db.execute(
221
+ "INSERT OR REPLACE INTO audit_index "
222
+ "(record_id, schema_key, project_id, ts, file_path, byte_offset) "
223
+ "VALUES (?, ?, ?, ?, '', 0)",
224
+ (rec.record_id, rec.schema_key, rec.project_id, rec.timestamp),
225
+ )
226
+ self._db.commit()
227
+ except Exception as exc: # pragma: no cover
228
+ _log.warning("sf-audit: SQLite index write failed: %s", exc)
229
+
230
+ def append_trust(self, trust_rec: dict[str, Any]) -> None:
231
+ with self._lock:
232
+ self._trust_records.append(trust_rec)
233
+
234
+ def query(
235
+ self,
236
+ schema_key: str | None,
237
+ project_id: str | None,
238
+ from_ts: str | None,
239
+ to_ts: str | None,
240
+ ) -> list[dict[str, Any]]:
241
+ """Query records using the SQLite index for O(log n) date-range access."""
242
+ with self._lock:
243
+ if self._db is not None:
244
+ return self._query_via_db(schema_key, project_id, from_ts, to_ts)
245
+ return self._query_linear(schema_key, project_id, from_ts, to_ts)
246
+
247
+ def _query_via_db(
248
+ self,
249
+ schema_key: str | None,
250
+ project_id: str | None,
251
+ from_ts: str | None,
252
+ to_ts: str | None,
253
+ ) -> list[dict[str, Any]]:
254
+ """Use the SQLite index to find matching record IDs, then hydrate."""
255
+ assert self._db is not None # nosec B101 — guarded by caller
256
+ # Build a parameterized query — all filter values are bound params,
257
+ # never interpolated, so there is no SQL injection risk.
258
+ clauses: list[str] = []
259
+ params: list[Any] = []
260
+ if schema_key:
261
+ clauses.append("schema_key = ?")
262
+ params.append(schema_key)
263
+ if project_id:
264
+ clauses.append("project_id = ?")
265
+ params.append(project_id)
266
+ if from_ts:
267
+ clauses.append("ts >= ?")
268
+ params.append(from_ts)
269
+ if to_ts:
270
+ clauses.append("ts <= ?")
271
+ params.append(to_ts)
272
+
273
+ where = ("WHERE " + " AND ".join(clauses)) if clauses else ""
274
+ sql = f"SELECT record_id FROM audit_index {where} ORDER BY ts" # noqa: S608 # nosec B608
275
+ try:
276
+ rows = self._db.execute(sql, params).fetchall()
277
+ except Exception as exc: # pragma: no cover
278
+ _log.warning("sf-audit: SQLite query failed, falling back to linear: %s", exc)
279
+ return self._query_linear(schema_key, project_id, from_ts, to_ts)
280
+
281
+ record_map = {r.record_id: r for r in self._records}
282
+ return [_record_to_dict(record_map[row[0]]) for row in rows if row[0] in record_map]
283
+
284
+ def _query_linear(
285
+ self,
286
+ schema_key: str | None,
287
+ project_id: str | None,
288
+ from_ts: str | None,
289
+ to_ts: str | None,
290
+ ) -> list[dict[str, Any]]:
291
+ """Fallback linear scan when SQLite is unavailable."""
292
+ results = []
293
+ for rec in self._records:
294
+ if schema_key and rec.schema_key != schema_key:
295
+ continue
296
+ if project_id and rec.project_id != project_id:
297
+ continue
298
+ if from_ts and rec.timestamp < from_ts:
299
+ continue
300
+ if to_ts and rec.timestamp > to_ts:
301
+ continue
302
+ results.append(_record_to_dict(rec))
303
+ return results
304
+
305
+ def query_trust(
306
+ self, project_id: str | None, from_ts: str | None, to_ts: str | None
307
+ ) -> list[dict[str, Any]]:
308
+ with self._lock:
309
+ results = []
310
+ for rec in self._trust_records:
311
+ if project_id and rec.get("project_id") != project_id:
312
+ continue
313
+ ts = rec.get("timestamp", "")
314
+ if from_ts and ts < from_ts:
315
+ continue
316
+ if to_ts and ts > to_ts:
317
+ continue
318
+ results.append(rec)
319
+ return results
320
+
321
+ @property
322
+ def record_count(self) -> int:
323
+ with self._lock:
324
+ return len(self._records)
325
+
326
+ @property
327
+ def last_append_at(self) -> str | None:
328
+ with self._lock:
329
+ if not self._records:
330
+ return None
331
+ return self._records[-1].timestamp
332
+
333
+ @property
334
+ def index_healthy(self) -> bool:
335
+ with self._lock:
336
+ return self._db is not None
337
+
338
+ def close(self) -> None:
339
+ with self._lock:
340
+ if self._db is not None:
341
+ with contextlib.suppress(Exception): # pragma: no cover
342
+ self._db.close()
343
+ self._db = None
344
+
345
+
346
+ def _record_to_dict(rec: _AuditRecord) -> dict[str, Any]:
347
+ return {
348
+ "record_id": rec.record_id,
349
+ "schema_key": rec.schema_key,
350
+ "project_id": rec.project_id,
351
+ "timestamp": rec.timestamp,
352
+ "hmac": rec.hmac,
353
+ "chain_position": rec.chain_position,
354
+ **rec.payload,
355
+ }
356
+
357
+
358
+ # ---------------------------------------------------------------------------
359
+ # HMAC helpers (low-level, no Event dependency)
360
+ # ---------------------------------------------------------------------------
361
+
362
+ _HMAC_ALGO = "sha256"
363
+ _FALLBACK_SIGNING_KEY = "spanforge-audit-local-insecure-dev-key" # nosec B105
364
+
365
+
366
+ def _utc_now_iso() -> str:
367
+ """Return the current UTC time as an ISO-8601 microsecond-precision string."""
368
+ return datetime.now(tz=timezone.utc).isoformat(timespec="microseconds").replace("+00:00", "Z")
369
+
370
+
371
+ def _compute_record_hmac(record_id: str, payload_json: str, org_secret: str) -> str:
372
+ """Compute ``"hmac-sha256:<hex>"`` for a raw dict audit record."""
373
+ msg = f"{record_id}|{payload_json}"
374
+ mac = _hmac.new(
375
+ key=org_secret.encode("utf-8"),
376
+ msg=msg.encode("utf-8"),
377
+ digestmod=hashlib.sha256,
378
+ )
379
+ return f"hmac-sha256:{mac.hexdigest()}"
380
+
381
+
382
+ def _compute_dict_checksum(payload: dict[str, Any]) -> str:
383
+ """Return ``"sha256:<hex>"`` of the canonical JSON of *payload*."""
384
+ canonical = json.dumps(payload, sort_keys=True, separators=(",", ":"), ensure_ascii=False)
385
+ digest = hashlib.sha256(canonical.encode("utf-8")).hexdigest()
386
+ return f"sha256:{digest}"
387
+
388
+
389
+ # ---------------------------------------------------------------------------
390
+ # T.R.U.S.T. store helpers (AUD-030)
391
+ # ---------------------------------------------------------------------------
392
+
393
+ _TRUST_DIMENSION_MAP = _TRUST_FEED_SCHEMAS
394
+
395
+ _TRUST_TREND_THRESHOLD: float = 2.0
396
+
397
+
398
+ def _build_trust_record( # noqa: PLR0913
399
+ schema_key: str,
400
+ record_id: str,
401
+ project_id: str,
402
+ payload: dict[str, Any],
403
+ hmac_value: str,
404
+ timestamp: str,
405
+ ) -> dict[str, Any]:
406
+ """Build a T.R.U.S.T. store record from an audit append."""
407
+ trust_dim = _TRUST_DIMENSION_MAP.get(schema_key, "compliance_posture")
408
+ return {
409
+ "trust_dimension": trust_dim,
410
+ "signal_source": "halluccheck",
411
+ "project_id": project_id,
412
+ "record_type": schema_key,
413
+ "record_id": record_id,
414
+ "verdict": payload.get("verdict") or payload.get("status") or "unknown",
415
+ "score": payload.get("score") or payload.get("hri") or 0.0,
416
+ "domain": payload.get("domain") or payload.get("model") or "",
417
+ "hmac": hmac_value,
418
+ "timestamp": timestamp,
419
+ }
420
+
421
+
422
+ # ---------------------------------------------------------------------------
423
+ # Scorecard computation helpers (AUD-031)
424
+ # ---------------------------------------------------------------------------
425
+
426
+ _DIMENSION_NAMES = (
427
+ "hallucination",
428
+ "pii_hygiene",
429
+ "secrets_hygiene",
430
+ "gate_pass_rate",
431
+ "compliance_posture",
432
+ )
433
+
434
+ _SCHEMA_TO_DIM: dict[str, str] = {
435
+ "hallucination": "halluccheck.score.v1",
436
+ "pii_hygiene": "halluccheck.pii.v1",
437
+ "secrets_hygiene": "halluccheck.secrets.v1",
438
+ "gate_pass_rate": "halluccheck.gate.v1", # nosec B105
439
+ "compliance_posture": "halluccheck.opa.v1",
440
+ }
441
+
442
+
443
+ def _compute_dimension_score(records: list[dict[str, Any]]) -> tuple[float, str]:
444
+ """Compute score (0-100) and trend from a list of trust records.
445
+
446
+ Returns ``(score, trend)`` where trend is ``"up"``, ``"flat"``, or ``"down"``.
447
+ """
448
+ if not records:
449
+ return 50.0, "flat"
450
+
451
+ raw_scores = []
452
+ for r in records:
453
+ s = r.get("score")
454
+ try:
455
+ v = float(s) # type: ignore[arg-type]
456
+ except (TypeError, ValueError):
457
+ v = 0.5
458
+ # Normalise [0,1] → [0,100]; values already >1 assumed to be 0-100
459
+ raw_scores.append(v * 100 if v <= 1.0 else min(v, 100.0))
460
+
461
+ if not raw_scores:
462
+ return 50.0, "flat"
463
+
464
+ avg = sum(raw_scores) / len(raw_scores)
465
+
466
+ # Trend: compare first half vs second half
467
+ mid = max(1, len(raw_scores) // 2)
468
+ first_half = sum(raw_scores[:mid]) / mid
469
+ second_half = sum(raw_scores[mid:]) / max(1, len(raw_scores) - mid)
470
+
471
+ delta = second_half - first_half
472
+ if delta > _TRUST_TREND_THRESHOLD:
473
+ trend = "up"
474
+ elif delta < -_TRUST_TREND_THRESHOLD:
475
+ trend = "down"
476
+ else:
477
+ trend = "flat"
478
+
479
+ return round(avg, 2), trend
480
+
481
+
482
+ # ---------------------------------------------------------------------------
483
+ # RFC 3161 DER encoding helpers
484
+ # ---------------------------------------------------------------------------
485
+
486
+
487
+ def _der_length(n: int) -> bytes:
488
+ """Return the DER length encoding for *n* bytes."""
489
+ if n < 0x80:
490
+ return bytes([n])
491
+ if n < 0x100:
492
+ return bytes([0x81, n])
493
+ if n < 0x10000:
494
+ return bytes([0x82, (n >> 8) & 0xFF, n & 0xFF])
495
+ raise ValueError(f"DER length too large: {n}") # pragma: no cover
496
+
497
+
498
+ # ---------------------------------------------------------------------------
499
+ # SFAuditClient
500
+ # ---------------------------------------------------------------------------
501
+
502
+
503
+ class SFAuditClient(SFServiceClient):
504
+ """SpanForge sf-audit high-level service client (Phase 4).
505
+
506
+ Provides a single ``append(record, schema_key)`` call site for writing
507
+ tamper-evident audit records, wrapping the low-level
508
+ :class:`~spanforge.signing.AuditStream` and
509
+ :class:`~spanforge.export.append_only.AppendOnlyJSONLExporter`
510
+ primitives.
511
+
512
+ Args:
513
+ config: Client configuration.
514
+ strict_schema: If ``True`` (default), reject unknown schema keys
515
+ with :exc:`~spanforge.sdk._exceptions.SFAuditSchemaError`.
516
+ Set to ``False`` to allow custom schema keys.
517
+ retention_years: Retention policy in years (default: 7).
518
+ byos_provider: BYOS provider name (``"s3"``, ``"azure"``, ``"gcs"``,
519
+ ``"r2"``), or ``None`` for local mode.
520
+ db_path: Path to the SQLite index database file.
521
+ Defaults to a temp file when ``None``.
522
+ persist_index: If ``True``, the SQLite file survives restarts.
523
+ If ``False`` (default), the temp file is removed on
524
+ :meth:`close`.
525
+
526
+ Example::
527
+
528
+ from spanforge.sdk import sf_audit
529
+
530
+ result = sf_audit.append(
531
+ {"model": "gpt-4o", "verdict": "PASS", "score": 0.91},
532
+ schema_key="halluccheck.score.v1",
533
+ )
534
+ print(result.record_id)
535
+ print(result.chain_position)
536
+ """
537
+
538
+ def __init__( # noqa: PLR0913
539
+ self,
540
+ config: SFClientConfig,
541
+ *,
542
+ strict_schema: bool = True,
543
+ retention_years: int = _DEFAULT_RETENTION_YEARS,
544
+ byos_provider: str | None = None,
545
+ db_path: str | None = None,
546
+ persist_index: bool = False,
547
+ ) -> None:
548
+ super().__init__(config, service_name="audit")
549
+ self._strict_schema = strict_schema
550
+ self._retention_years = retention_years
551
+ self._byos_provider = byos_provider or _detect_byos_provider()
552
+ self._chain_lock = threading.Lock()
553
+ self._chain_position: int = 0
554
+ self._last_hmac: str | None = None
555
+
556
+ # SQLite index
557
+ if db_path:
558
+ self._db_path = db_path
559
+ self._persist_index = True
560
+ else:
561
+ fd, tmp = tempfile.mkstemp(suffix=".db", prefix="sf_audit_")
562
+ os.close(fd)
563
+ self._db_path = tmp
564
+ self._persist_index = persist_index
565
+
566
+ self._store = _LocalAuditStore(self._db_path)
567
+
568
+ # ------------------------------------------------------------------
569
+ # AUD-001: append
570
+ # ------------------------------------------------------------------
571
+
572
+ def append(
573
+ self,
574
+ record: dict[str, Any],
575
+ schema_key: str,
576
+ *,
577
+ project_id: str = "",
578
+ strict_schema: bool | None = None,
579
+ ) -> AuditAppendResult:
580
+ """Append a signed, tamper-evident audit record to the store.
581
+
582
+ Steps:
583
+
584
+ 1. Validate *schema_key* against the registry (AUD-002).
585
+ 2. Generate a unique ``record_id`` (UUID4).
586
+ 3. Compute HMAC-SHA256 signature over ``record_id + canonical_json``.
587
+ 4. Write the record to the local store and update the SQLite index.
588
+ 5. If *schema_key* is in :data:`_TRUST_FEED_SCHEMAS`, write a
589
+ T.R.U.S.T. summary record (AUD-030).
590
+ 6. Return :class:`AuditAppendResult`.
591
+
592
+ Args:
593
+ record: The audit record payload. Must be a ``dict``.
594
+ schema_key: Schema namespace key (e.g. ``"halluccheck.score.v1"``).
595
+ project_id: Optional project scope. Falls back to
596
+ ``config.project_id``.
597
+ strict_schema: Override the instance ``strict_schema`` flag for
598
+ this call only.
599
+
600
+ Returns:
601
+ :class:`~spanforge.sdk._types.AuditAppendResult`
602
+
603
+ Raises:
604
+ SFAuditSchemaError: Unknown schema key when ``strict_schema=True``.
605
+ SFAuditAppendError: Record payload is not a ``dict``.
606
+ """
607
+ if not isinstance(record, dict):
608
+ raise SFAuditAppendError(f"record must be a dict; got {type(record).__name__}")
609
+
610
+ effective_strict = strict_schema if strict_schema is not None else self._strict_schema
611
+ if effective_strict and schema_key not in KNOWN_SCHEMA_KEYS:
612
+ raise SFAuditSchemaError(schema_key, KNOWN_SCHEMA_KEYS)
613
+
614
+ effective_project = project_id or self._config.project_id
615
+
616
+ record_id = str(uuid.uuid4())
617
+ timestamp = _utc_now_iso()
618
+
619
+ # Canonical JSON for HMAC
620
+ enriched = {
621
+ "record_id": record_id,
622
+ "schema_key": schema_key,
623
+ "project_id": effective_project,
624
+ "timestamp": timestamp,
625
+ **record,
626
+ }
627
+ canonical = json.dumps(enriched, sort_keys=True, separators=(",", ":"), ensure_ascii=False)
628
+
629
+ org_secret = self._config.signing_key or _FALLBACK_SIGNING_KEY
630
+ hmac_value = _compute_record_hmac(record_id, canonical, org_secret)
631
+
632
+ with self._chain_lock:
633
+ position = self._chain_position
634
+ self._chain_position += 1
635
+ self._last_hmac = hmac_value
636
+
637
+ audit_record = _AuditRecord(
638
+ record_id=record_id,
639
+ schema_key=schema_key,
640
+ project_id=effective_project,
641
+ timestamp=timestamp,
642
+ hmac=hmac_value,
643
+ chain_position=position,
644
+ payload=record,
645
+ )
646
+ self._store.append(audit_record)
647
+
648
+ # AUD-030: T.R.U.S.T. store write
649
+ if schema_key in _TRUST_FEED_SCHEMAS:
650
+ trust_rec = _build_trust_record(
651
+ schema_key, record_id, effective_project, record, hmac_value, timestamp
652
+ )
653
+ self._store.append_trust(trust_rec)
654
+
655
+ backend = self._byos_provider or "local"
656
+ _log.debug(
657
+ "sf-audit: appended record_id=%s schema=%s pos=%d backend=%s",
658
+ record_id,
659
+ schema_key,
660
+ position,
661
+ backend,
662
+ )
663
+
664
+ return AuditAppendResult(
665
+ record_id=record_id,
666
+ chain_position=position,
667
+ timestamp=timestamp,
668
+ hmac=hmac_value,
669
+ schema_key=schema_key,
670
+ backend=backend,
671
+ )
672
+
673
+ # ------------------------------------------------------------------
674
+ # AUD-003: sign
675
+ # ------------------------------------------------------------------
676
+
677
+ def sign(self, record: dict[str, Any]) -> SignedRecord:
678
+ """Sign a raw dict with HMAC-SHA256.
679
+
680
+ This is the **only** signing call site for HallucCheck — callers
681
+ must not import from :mod:`spanforge.signing` directly.
682
+
683
+ Args:
684
+ record: The record dict to sign.
685
+
686
+ Returns:
687
+ :class:`~spanforge.sdk._types.SignedRecord`
688
+
689
+ Raises:
690
+ SFAuditAppendError: If *record* is not a ``dict``.
691
+ """
692
+ if not isinstance(record, dict):
693
+ raise SFAuditAppendError(f"sign() requires a dict; got {type(record).__name__}")
694
+
695
+ record_id = str(uuid.uuid4())
696
+ timestamp = _utc_now_iso()
697
+ canonical = json.dumps(record, sort_keys=True, separators=(",", ":"), ensure_ascii=False)
698
+ checksum = f"sha256:{hashlib.sha256(canonical.encode()).hexdigest()}"
699
+
700
+ org_secret = self._config.signing_key or _FALLBACK_SIGNING_KEY
701
+ hmac_value = _compute_record_hmac(record_id, canonical, org_secret)
702
+
703
+ return SignedRecord(
704
+ record=dict(record),
705
+ record_id=record_id,
706
+ checksum=checksum,
707
+ signature=hmac_value,
708
+ timestamp=timestamp,
709
+ )
710
+
711
+ # ------------------------------------------------------------------
712
+ # AUD-004: verify_chain (wraps signing.verify_chain)
713
+ # ------------------------------------------------------------------
714
+
715
+ def verify_chain(
716
+ self,
717
+ records: list[dict[str, Any]],
718
+ *,
719
+ org_secret: str | None = None,
720
+ ) -> dict[str, Any]:
721
+ """Verify the HMAC chain across a list of signed audit dicts.
722
+
723
+ For each consecutive pair ``(records[n-1], records[n])``, checks:
724
+
725
+ 1. ``record[n]["hmac"]`` is valid for ``record[n]["record_id"]`` and its canonical JSON.
726
+ 2. The records are in ascending ``chain_position`` order.
727
+
728
+ Args:
729
+ records: Ordered list of audit record dicts (as returned by
730
+ :meth:`export`). Each must have ``record_id``,
731
+ ``hmac``, and ``chain_position`` fields.
732
+ org_secret: HMAC key to use for verification. Defaults to
733
+ ``config.signing_key``.
734
+
735
+ Returns:
736
+ ``{"valid": bool, "tampered_count": int, "first_tampered": str|None,
737
+ "gaps": list[str], "verified_count": int}``
738
+
739
+ Raises:
740
+ SFAuditQueryError: If *records* is not a list.
741
+ """
742
+ if not isinstance(records, list):
743
+ raise SFAuditQueryError(
744
+ f"verify_chain() requires a list of dicts; got {type(records).__name__}"
745
+ )
746
+
747
+ secret = org_secret or self._config.signing_key or _FALLBACK_SIGNING_KEY
748
+
749
+ first_tampered: str | None = None
750
+ tampered_count = 0
751
+ gaps: list[str] = []
752
+ prev_position: int | None = None
753
+
754
+ for rec in records:
755
+ if not isinstance(rec, dict):
756
+ tampered_count += 1
757
+ continue
758
+
759
+ rid = rec.get("record_id", "")
760
+ stored_hmac = rec.get("hmac", "")
761
+
762
+ # Re-derive canonical JSON — exclude fields not in the original HMAC input
763
+ payload = {k: v for k, v in rec.items() if k not in ("hmac", "chain_position")}
764
+ canonical = json.dumps(
765
+ payload, sort_keys=True, separators=(",", ":"), ensure_ascii=False
766
+ )
767
+ expected_hmac = _compute_record_hmac(rid, canonical, secret)
768
+
769
+ valid = _hmac.compare_digest(stored_hmac, expected_hmac)
770
+ if not valid:
771
+ tampered_count += 1
772
+ if first_tampered is None:
773
+ first_tampered = rid
774
+
775
+ pos = rec.get("chain_position")
776
+ if pos is not None and prev_position is not None and int(pos) != int(prev_position) + 1:
777
+ gaps.append(rid)
778
+ prev_position = pos
779
+
780
+ return {
781
+ "valid": tampered_count == 0 and not gaps,
782
+ "tampered_count": tampered_count,
783
+ "first_tampered": first_tampered,
784
+ "gaps": gaps,
785
+ "verified_count": len(records),
786
+ }
787
+
788
+ # ------------------------------------------------------------------
789
+ # AUD-005: export
790
+ # ------------------------------------------------------------------
791
+
792
+ def export(
793
+ self,
794
+ schema_key: str | None = None,
795
+ *,
796
+ date_range: tuple[str | None, str | None] | None = None,
797
+ project_id: str | None = None,
798
+ limit: int = 10_000,
799
+ ) -> list[dict[str, Any]]:
800
+ """Query audit records by schema key, date range, and project.
801
+
802
+ Uses the SQLite index for O(log n) date-range access without a full
803
+ file scan. Falls back to linear scan when the index is unavailable.
804
+
805
+ Args:
806
+ schema_key: Filter to a specific schema key, or ``None`` for all.
807
+ date_range: Optional ``(from_iso, to_iso)`` tuple. Either element
808
+ may be ``None`` for open-ended ranges. ISO-8601 UTC.
809
+ project_id: Filter to a specific project, or ``None`` for all.
810
+ limit: Maximum records to return (default: 10 000).
811
+
812
+ Returns:
813
+ List of audit record dicts, sorted by ``timestamp`` ascending.
814
+
815
+ Raises:
816
+ SFAuditQueryError: If *schema_key* is provided and does not match
817
+ any known schema (when ``strict_schema=True``).
818
+ """
819
+ if schema_key is not None and self._strict_schema and schema_key not in KNOWN_SCHEMA_KEYS:
820
+ raise SFAuditQueryError(
821
+ f"Unknown schema_key {schema_key!r} for export query. "
822
+ "Pass strict_schema=False or use a known schema key."
823
+ )
824
+
825
+ from_ts: str | None = None
826
+ to_ts: str | None = None
827
+ if date_range:
828
+ from_ts, to_ts = date_range[0], date_range[1]
829
+
830
+ results = self._store.query(schema_key, project_id, from_ts, to_ts)
831
+ return results[:limit]
832
+
833
+ # ------------------------------------------------------------------
834
+ # AUD-030 / AUD-031: T.R.U.S.T. scorecard
835
+ # ------------------------------------------------------------------
836
+
837
+ def get_trust_scorecard(
838
+ self,
839
+ project_id: str | None = None,
840
+ *,
841
+ from_dt: str | None = None,
842
+ to_dt: str | None = None,
843
+ ) -> TrustScorecard:
844
+ """Return the aggregated T.R.U.S.T. scorecard for *project_id*.
845
+
846
+ Aggregates all T.R.U.S.T. summary records appended via
847
+ :meth:`append` and computes per-dimension scores with trend signals.
848
+
849
+ Args:
850
+ project_id: Scoping project. Defaults to ``config.project_id``.
851
+ from_dt: ISO-8601 UTC start of reporting window.
852
+ to_dt: ISO-8601 UTC end of reporting window.
853
+
854
+ Returns:
855
+ :class:`~spanforge.sdk._types.TrustScorecard`
856
+ """
857
+ effective_project = project_id or self._config.project_id
858
+ now_iso = _utc_now_iso()
859
+ from_iso = from_dt or "1970-01-01T00:00:00.000000Z"
860
+ to_iso = to_dt or now_iso
861
+
862
+ trust_records = self._store.query_trust(effective_project or None, from_iso, to_iso)
863
+
864
+ # Bucket records by dimension
865
+ by_dim: dict[str, list[dict[str, Any]]] = {d: [] for d in _DIMENSION_NAMES}
866
+ for rec in trust_records:
867
+ dim = rec.get("trust_dimension", "compliance_posture")
868
+ if dim in by_dim:
869
+ by_dim[dim].append(rec)
870
+
871
+ def _dim(name: str) -> TrustDimension:
872
+ recs = by_dim[name]
873
+ score, trend = _compute_dimension_score(recs)
874
+ last_rec = recs[-1] if recs else None
875
+ last_ts = last_rec["timestamp"] if last_rec else now_iso
876
+ return TrustDimension(score=score, trend=trend, last_updated=last_ts)
877
+
878
+ return TrustScorecard(
879
+ project_id=effective_project,
880
+ from_dt=from_iso,
881
+ to_dt=to_iso,
882
+ hallucination=_dim("hallucination"),
883
+ pii_hygiene=_dim("pii_hygiene"),
884
+ secrets_hygiene=_dim("secrets_hygiene"),
885
+ gate_pass_rate=_dim("gate_pass_rate"),
886
+ compliance_posture=_dim("compliance_posture"),
887
+ record_count=len(trust_records),
888
+ )
889
+
890
+ # ------------------------------------------------------------------
891
+ # AUD-042: generate_article30_record (GDPR)
892
+ # ------------------------------------------------------------------
893
+
894
+ def generate_article30_record(
895
+ self,
896
+ project_id: str | None = None,
897
+ *,
898
+ controller_name: str = "Data Controller",
899
+ processor_name: str = "SpanForge (AI Observability Platform)",
900
+ third_country: bool = False,
901
+ retention_period: str | None = None,
902
+ ) -> Article30Record:
903
+ """Generate a GDPR Article 30 Record of Processing Activities (RoPA).
904
+
905
+ Produces a structured document conforming to GDPR Article 30 §1
906
+ requirements, based on the audit metadata for *project_id*.
907
+
908
+ Args:
909
+ project_id: Scoping project. Defaults to ``config.project_id``.
910
+ controller_name: Name of the data controller.
911
+ processor_name: Name of the processor (default: SpanForge).
912
+ third_country: Whether data is transferred to a third country.
913
+ retention_period: Override the default retention description.
914
+
915
+ Returns:
916
+ :class:`~spanforge.sdk._types.Article30Record`
917
+ """
918
+ effective_project = project_id or self._config.project_id
919
+ now_iso = _utc_now_iso()
920
+
921
+ soc2_ref = "SOC 2 / HIPAA / GDPR Article 5(1)(e)"
922
+ ret_desc = retention_period or (
923
+ f"{self._retention_years} years (WORM-compliant, per {soc2_ref})"
924
+ )
925
+
926
+ return Article30Record(
927
+ project_id=effective_project,
928
+ controller_name=controller_name,
929
+ processor_name=processor_name,
930
+ processing_purposes=[
931
+ "AI output quality assurance and hallucination detection",
932
+ "PII detection and redaction for GDPR/DPDP/HIPAA compliance",
933
+ "Secrets scanning and data leakage prevention",
934
+ "Audit logging for regulatory compliance evidence",
935
+ "Model drift and bias monitoring",
936
+ ],
937
+ data_categories=[
938
+ "AI-generated text outputs",
939
+ "Model request/response metadata",
940
+ "PII detection results (entity types only — no raw PII stored)",
941
+ "Audit chain integrity hashes",
942
+ ],
943
+ data_subjects=[
944
+ "End users of AI-powered applications",
945
+ "Application operators",
946
+ ],
947
+ recipients=[
948
+ "Data controller (internal audit teams)",
949
+ "External auditors (under NDA / DPA)",
950
+ "Regulatory authorities (upon lawful request)",
951
+ ],
952
+ third_country=third_country,
953
+ retention_period=ret_desc,
954
+ security_measures=[
955
+ "HMAC-SHA256 tamper-evident audit chain",
956
+ "WORM (Write-Once-Read-Many) append-only storage",
957
+ "TLS 1.2+ encryption in transit",
958
+ "AES-256 encryption at rest (BYOS provider dependent)",
959
+ "Role-based access control via sf-identity",
960
+ "SOC 2 Type II, ISO 27001-aligned controls",
961
+ ],
962
+ generated_at=now_iso,
963
+ record_id=str(uuid.uuid4()),
964
+ )
965
+
966
+ # ------------------------------------------------------------------
967
+ # Status endpoint contribution
968
+ # ------------------------------------------------------------------
969
+
970
+ def get_status(self) -> AuditStatusInfo:
971
+ """Return sf-audit service health information.
972
+
973
+ Contributes to ``GET /v1/spanforge/status``.
974
+
975
+ Returns:
976
+ :class:`~spanforge.sdk._types.AuditStatusInfo`
977
+ """
978
+ return AuditStatusInfo(
979
+ status="ok",
980
+ backend=self._byos_provider or "local",
981
+ byos_enabled=self._byos_provider is not None,
982
+ record_count=self._store.record_count,
983
+ last_append_at=self._store.last_append_at,
984
+ schema_count=len(KNOWN_SCHEMA_KEYS),
985
+ index_healthy=self._store.index_healthy,
986
+ retention_years=self._retention_years,
987
+ )
988
+
989
+ # ------------------------------------------------------------------
990
+ # Lifecycle
991
+ # ------------------------------------------------------------------
992
+
993
+ async def append_async(
994
+ self,
995
+ record: dict[str, Any],
996
+ schema_key: str,
997
+ *,
998
+ project_id: str = "",
999
+ strict_schema: bool | None = None,
1000
+ ) -> AuditAppendResult:
1001
+ """Async variant of :meth:`append`.
1002
+
1003
+ Runs the append (including HMAC computation and SQLite write) in the
1004
+ default executor so the event loop is not blocked.
1005
+
1006
+ Args:
1007
+ record: The audit record payload.
1008
+ schema_key: Schema namespace key.
1009
+ project_id: Optional project scope.
1010
+ strict_schema: Override the instance ``strict_schema`` flag.
1011
+
1012
+ Returns:
1013
+ :class:`~spanforge.sdk._types.AuditAppendResult`.
1014
+ """
1015
+ import asyncio
1016
+ import functools
1017
+
1018
+ loop = asyncio.get_event_loop()
1019
+ return await loop.run_in_executor(
1020
+ None,
1021
+ functools.partial(
1022
+ self.append,
1023
+ record,
1024
+ schema_key,
1025
+ project_id=project_id,
1026
+ strict_schema=strict_schema,
1027
+ ),
1028
+ )
1029
+
1030
+ # ------------------------------------------------------------------
1031
+ # AUD-RFC3161: RFC 3161 Trusted Timestamp Authority integration
1032
+ # ------------------------------------------------------------------
1033
+
1034
+ #: Public TSA endpoints used by :meth:`stamp_with_tsa`.
1035
+ TSA_ENDPOINTS: tuple[str, ...] = (
1036
+ "http://timestamp.digicert.com",
1037
+ "https://freetsa.org/tsr",
1038
+ )
1039
+
1040
+ def stamp_with_tsa(
1041
+ self,
1042
+ record_data: dict[str, Any],
1043
+ *,
1044
+ tsa_url: str | None = None,
1045
+ timeout_seconds: int = 10,
1046
+ ) -> dict[str, Any]:
1047
+ """Obtain an RFC 3161 trusted timestamp for *record_data*.
1048
+
1049
+ Builds a minimal RFC 3161 ``TimeStampReq`` message (hash-only, no
1050
+ nonce extension), POSTs it to a qualified Timestamp Authority, and
1051
+ returns a structured result containing the TSA URL, the SHA-256 hash
1052
+ of the canonicalised record, and the raw DER response bytes encoded
1053
+ as hexadecimal.
1054
+
1055
+ Args:
1056
+ record_data: The audit record dict to timestamp. The SHA-256
1057
+ hash of its canonical JSON is sent to the TSA.
1058
+ tsa_url: Override the default TSA URL. If ``None``, the
1059
+ first reachable URL in :attr:`TSA_ENDPOINTS` is
1060
+ used.
1061
+ timeout_seconds: HTTP request timeout in seconds (default: 10).
1062
+
1063
+ Returns:
1064
+ ``{"tsa_url": str, "hash_algorithm": "sha256", "message_imprint": str,
1065
+ "tsr_hex": str, "stamped_at": str}``
1066
+
1067
+ Raises:
1068
+ SFAuditAppendError: If the TSA returns a non-200 HTTP status or the
1069
+ response body is empty.
1070
+ SFAuditAppendError: If *record_data* is not a dict.
1071
+
1072
+ Note:
1073
+ This method makes a real outbound HTTPS request. In test
1074
+ environments, patch :func:`urllib.request.urlopen` to avoid
1075
+ network calls — the request format is validated by the unit tests.
1076
+ """
1077
+ import struct
1078
+ import urllib.error
1079
+ import urllib.request
1080
+
1081
+ if not isinstance(record_data, dict):
1082
+ raise SFAuditAppendError(
1083
+ f"stamp_with_tsa() requires a dict; got {type(record_data).__name__}"
1084
+ )
1085
+
1086
+ # 1. Compute SHA-256 of canonical JSON
1087
+ canonical = json.dumps(
1088
+ record_data, sort_keys=True, separators=(",", ":"), ensure_ascii=False
1089
+ )
1090
+ digest = hashlib.sha256(canonical.encode("utf-8")).digest()
1091
+ message_imprint_hex = digest.hex()
1092
+
1093
+ # 2. Build a minimal RFC 3161 TimeStampReq in DER encoding.
1094
+ # Structure (simplified, hash-only, no nonce, no certReq):
1095
+ # SEQUENCE {
1096
+ # INTEGER 1 -- version
1097
+ # SEQUENCE { -- messageImprint
1098
+ # SEQUENCE { -- hashAlgorithm (sha256 OID)
1099
+ # OID 2.16.840.1.101.3.4.2.1
1100
+ # NULL
1101
+ # }
1102
+ # OCTET STRING <32-byte digest>
1103
+ # }
1104
+ # }
1105
+ sha256_oid = bytes([
1106
+ 0x30, 0x0d, # SEQUENCE (13 bytes)
1107
+ 0x06, 0x09, # OID (9 bytes)
1108
+ 0x60, 0x86, 0x48, 0x01, 0x65, 0x03, 0x04, 0x02, 0x01, # sha-256
1109
+ 0x05, 0x00, # NULL
1110
+ ])
1111
+ octet_string = bytes([0x04, 0x20]) + digest # OCTET STRING (32 bytes)
1112
+ message_imprint = bytes([0x30]) + _der_length(len(sha256_oid) + len(octet_string)) + sha256_oid + octet_string
1113
+ version = bytes([0x02, 0x01, 0x01]) # INTEGER 1
1114
+ tsa_req_body = version + message_imprint
1115
+ tsa_req = bytes([0x30]) + _der_length(len(tsa_req_body)) + tsa_req_body
1116
+
1117
+ # 3. POST to TSA
1118
+ url = tsa_url or self.TSA_ENDPOINTS[0]
1119
+ req = urllib.request.Request(
1120
+ url,
1121
+ data=tsa_req,
1122
+ headers={"Content-Type": "application/timestamp-query"},
1123
+ method="POST",
1124
+ )
1125
+ try:
1126
+ with urllib.request.urlopen(req, timeout=timeout_seconds) as resp: # nosec B310
1127
+ status = resp.getcode()
1128
+ body = resp.read()
1129
+ except urllib.error.URLError as exc:
1130
+ raise SFAuditAppendError(
1131
+ f"RFC 3161 TSA request to {url!r} failed: {exc}"
1132
+ ) from exc
1133
+
1134
+ if status != 200 or not body:
1135
+ raise SFAuditAppendError(
1136
+ f"RFC 3161 TSA returned HTTP {status} from {url!r}"
1137
+ )
1138
+
1139
+ return {
1140
+ "tsa_url": url,
1141
+ "hash_algorithm": "sha256",
1142
+ "message_imprint": message_imprint_hex,
1143
+ "tsr_hex": body.hex(),
1144
+ "stamped_at": _utc_now_iso(),
1145
+ }
1146
+
1147
+ def verify_tsa_timestamp(
1148
+ self,
1149
+ tsa_result: dict[str, Any],
1150
+ record_data: dict[str, Any],
1151
+ ) -> dict[str, Any]:
1152
+ """Verify that *tsa_result* (from :meth:`stamp_with_tsa`) matches *record_data*.
1153
+
1154
+ Validates that the ``message_imprint`` in *tsa_result* matches the
1155
+ SHA-256 hash of the canonical JSON of *record_data*, and that the
1156
+ ``tsr_hex`` field is non-empty (i.e. the TSR was actually received).
1157
+
1158
+ Args:
1159
+ tsa_result: The dict returned by :meth:`stamp_with_tsa`.
1160
+ record_data: The audit record dict that was stamped.
1161
+
1162
+ Returns:
1163
+ ``{"valid": bool, "reason": str}``
1164
+ """
1165
+ if not isinstance(tsa_result, dict) or not isinstance(record_data, dict):
1166
+ return {"valid": False, "reason": "tsa_result and record_data must be dicts"}
1167
+
1168
+ required = {"tsa_url", "message_imprint", "tsr_hex"}
1169
+ if not required.issubset(tsa_result.keys()):
1170
+ missing = required - tsa_result.keys()
1171
+ return {"valid": False, "reason": f"tsa_result missing fields: {missing}"}
1172
+
1173
+ canonical = json.dumps(
1174
+ record_data, sort_keys=True, separators=(",", ":"), ensure_ascii=False
1175
+ )
1176
+ expected_imprint = hashlib.sha256(canonical.encode("utf-8")).hexdigest()
1177
+
1178
+ if not _hmac.compare_digest(
1179
+ tsa_result["message_imprint"].lower(), expected_imprint.lower()
1180
+ ):
1181
+ return {
1182
+ "valid": False,
1183
+ "reason": "message_imprint mismatch — record may have been tampered",
1184
+ }
1185
+
1186
+ if not tsa_result.get("tsr_hex"):
1187
+ return {"valid": False, "reason": "tsr_hex is empty — no TSA response was stored"}
1188
+
1189
+ return {"valid": True, "reason": "TSA timestamp verified"}
1190
+
1191
+ def close(self) -> None:
1192
+ """Release SQLite resources and optionally clean up the temp index file."""
1193
+ self._store.close()
1194
+ if not self._persist_index:
1195
+ with contextlib.suppress(Exception): # pragma: no cover
1196
+ Path(self._db_path).unlink(missing_ok=True)