spanforge 2.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 (101) hide show
  1. spanforge/__init__.py +695 -0
  2. spanforge/_batch_exporter.py +322 -0
  3. spanforge/_cli.py +3081 -0
  4. spanforge/_hooks.py +340 -0
  5. spanforge/_server.py +953 -0
  6. spanforge/_span.py +1015 -0
  7. spanforge/_store.py +287 -0
  8. spanforge/_stream.py +654 -0
  9. spanforge/_trace.py +334 -0
  10. spanforge/_tracer.py +253 -0
  11. spanforge/actor.py +141 -0
  12. spanforge/alerts.py +464 -0
  13. spanforge/auto.py +181 -0
  14. spanforge/baseline.py +336 -0
  15. spanforge/config.py +460 -0
  16. spanforge/consent.py +227 -0
  17. spanforge/consumer.py +379 -0
  18. spanforge/core/__init__.py +5 -0
  19. spanforge/core/compliance_mapping.py +1060 -0
  20. spanforge/cost.py +597 -0
  21. spanforge/debug.py +514 -0
  22. spanforge/drift.py +488 -0
  23. spanforge/egress.py +63 -0
  24. spanforge/eval.py +575 -0
  25. spanforge/event.py +1052 -0
  26. spanforge/exceptions.py +246 -0
  27. spanforge/explain.py +181 -0
  28. spanforge/export/__init__.py +50 -0
  29. spanforge/export/append_only.py +342 -0
  30. spanforge/export/cloud.py +349 -0
  31. spanforge/export/datadog.py +495 -0
  32. spanforge/export/grafana.py +331 -0
  33. spanforge/export/jsonl.py +198 -0
  34. spanforge/export/otel_bridge.py +291 -0
  35. spanforge/export/otlp.py +817 -0
  36. spanforge/export/otlp_bridge.py +231 -0
  37. spanforge/export/redis_backend.py +282 -0
  38. spanforge/export/webhook.py +302 -0
  39. spanforge/exporters/__init__.py +29 -0
  40. spanforge/exporters/console.py +271 -0
  41. spanforge/exporters/jsonl.py +144 -0
  42. spanforge/hitl.py +297 -0
  43. spanforge/inspect.py +429 -0
  44. spanforge/integrations/__init__.py +39 -0
  45. spanforge/integrations/_pricing.py +277 -0
  46. spanforge/integrations/anthropic.py +388 -0
  47. spanforge/integrations/bedrock.py +306 -0
  48. spanforge/integrations/crewai.py +251 -0
  49. spanforge/integrations/gemini.py +349 -0
  50. spanforge/integrations/groq.py +444 -0
  51. spanforge/integrations/langchain.py +349 -0
  52. spanforge/integrations/llamaindex.py +370 -0
  53. spanforge/integrations/ollama.py +286 -0
  54. spanforge/integrations/openai.py +370 -0
  55. spanforge/integrations/together.py +485 -0
  56. spanforge/metrics.py +393 -0
  57. spanforge/metrics_export.py +342 -0
  58. spanforge/migrate.py +278 -0
  59. spanforge/model_registry.py +282 -0
  60. spanforge/models.py +407 -0
  61. spanforge/namespaces/__init__.py +215 -0
  62. spanforge/namespaces/audit.py +253 -0
  63. spanforge/namespaces/cache.py +209 -0
  64. spanforge/namespaces/chain.py +74 -0
  65. spanforge/namespaces/confidence.py +69 -0
  66. spanforge/namespaces/consent.py +85 -0
  67. spanforge/namespaces/cost.py +175 -0
  68. spanforge/namespaces/decision.py +135 -0
  69. spanforge/namespaces/diff.py +146 -0
  70. spanforge/namespaces/drift.py +79 -0
  71. spanforge/namespaces/eval_.py +232 -0
  72. spanforge/namespaces/fence.py +180 -0
  73. spanforge/namespaces/guard.py +104 -0
  74. spanforge/namespaces/hitl.py +92 -0
  75. spanforge/namespaces/latency.py +69 -0
  76. spanforge/namespaces/prompt.py +185 -0
  77. spanforge/namespaces/redact.py +172 -0
  78. spanforge/namespaces/template.py +197 -0
  79. spanforge/namespaces/tool_call.py +76 -0
  80. spanforge/namespaces/trace.py +1006 -0
  81. spanforge/normalizer.py +183 -0
  82. spanforge/presidio_backend.py +149 -0
  83. spanforge/processor.py +258 -0
  84. spanforge/prompt_registry.py +415 -0
  85. spanforge/py.typed +0 -0
  86. spanforge/redact.py +780 -0
  87. spanforge/sampling.py +500 -0
  88. spanforge/schemas/v1.0/schema.json +170 -0
  89. spanforge/schemas/v2.0/schema.json +536 -0
  90. spanforge/signing.py +1152 -0
  91. spanforge/stream.py +559 -0
  92. spanforge/testing.py +376 -0
  93. spanforge/trace.py +199 -0
  94. spanforge/types.py +696 -0
  95. spanforge/ulid.py +304 -0
  96. spanforge/validate.py +383 -0
  97. spanforge-2.0.0.dist-info/METADATA +1777 -0
  98. spanforge-2.0.0.dist-info/RECORD +101 -0
  99. spanforge-2.0.0.dist-info/WHEEL +4 -0
  100. spanforge-2.0.0.dist-info/entry_points.txt +5 -0
  101. spanforge-2.0.0.dist-info/licenses/LICENSE +21 -0
spanforge/signing.py ADDED
@@ -0,0 +1,1152 @@
1
+ """HMAC-SHA256 signing and tamper-evident audit chain for spanforge.
2
+
3
+ Provides compliance-grade audit log integrity without requiring a blockchain
4
+ or external service. All cryptography uses pure Python stdlib — no network
5
+ calls, no external dependencies.
6
+
7
+ Signing algorithm
8
+ -----------------
9
+
10
+ Each event is signed in two steps::
11
+
12
+ checksum = "sha256:" + sha256(canonical_payload_json).hexdigest()
13
+ sig_input = event_id + "|" + checksum + "|" + (prev_id or "")
14
+ signature = "hmac-sha256:" + HMAC-SHA256(sig_input, org_secret).hexdigest()
15
+
16
+ The *canonical payload JSON* uses ``sort_keys=True, separators=(",", ":")``
17
+ (compact, no whitespace) so the same payload always produces the same checksum
18
+ regardless of dict insertion order or Python version.
19
+
20
+ Chain linkage
21
+ -------------
22
+
23
+ Each event (except the first) stores the ``prev_id`` of its predecessor.
24
+ A missing or mismatched ``prev_id`` indicates a deleted or reordered event::
25
+
26
+ events[n].prev_id == events[n-1].event_id # must hold for every n > 0
27
+
28
+ Key rotation
29
+ ------------
30
+
31
+ The HMAC key can be rotated mid-chain using :meth:`AuditStream.rotate_key`.
32
+ A key-rotation event (``EventType.AUDIT_KEY_ROTATED``) is inserted into the
33
+ chain, signed with the *current* key. All subsequent events are signed with
34
+ the *new* key. :func:`verify_chain` accepts a ``key_map`` argument that maps
35
+ rotation event IDs to the corresponding new secrets, enabling independent
36
+ chain verification across rotation boundaries.
37
+
38
+ Security requirements
39
+ ---------------------
40
+
41
+ * The ``org_secret`` **never** appears in exception messages, ``__repr__``,
42
+ ``__str__``, or ``__reduce__`` output.
43
+ * Signing failures always raise :exc:`~spanforge.exceptions.SigningError`
44
+ — never silently pass.
45
+ * Empty or whitespace-only secrets are rejected immediately.
46
+ * :func:`verify` uses :func:`hmac.compare_digest` for all comparisons to
47
+ prevent timing-based side-channel attacks.
48
+
49
+ Usage
50
+ -----
51
+ ::
52
+
53
+ from spanforge import Event, EventType
54
+ from spanforge.signing import sign, verify, verify_chain, AuditStream
55
+
56
+ # Sign a single event
57
+ signed = sign(event, org_secret="corp-key-001")
58
+ assert verify(signed, org_secret="corp-key-001")
59
+
60
+ # Build a verifiable chain
61
+ stream = AuditStream(org_secret="corp-key-001", source="audit-daemon@1.0.0")
62
+ for evt in raw_events:
63
+ stream.append(evt)
64
+
65
+ result = stream.verify()
66
+ # result.valid → True
67
+ # result.first_tampered → None
68
+ # result.gaps → []
69
+ # result.tampered_count → 0
70
+ """
71
+
72
+ from __future__ import annotations
73
+
74
+ import hashlib
75
+ import hmac as _hmac
76
+ import json
77
+ from dataclasses import dataclass, field
78
+ from typing import TYPE_CHECKING, Protocol as _Protocol, runtime_checkable as _runtime_checkable
79
+
80
+ from spanforge.exceptions import SigningError, VerificationError
81
+
82
+ if TYPE_CHECKING:
83
+ from collections.abc import Sequence
84
+
85
+ from spanforge.event import Event
86
+
87
+ __all__ = [
88
+ "AsyncAuditStream",
89
+ "AuditStream",
90
+ "ChainVerificationResult",
91
+ "DictKeyResolver",
92
+ "EnvKeyResolver",
93
+ "KeyResolver",
94
+ "StaticKeyResolver",
95
+ "_event_mentions_subject",
96
+ "assert_verified",
97
+ "check_key_expiry",
98
+ "derive_key",
99
+ "sign",
100
+ "validate_key_strength",
101
+ "verify",
102
+ "verify_chain",
103
+ ]
104
+
105
+
106
+ # ---------------------------------------------------------------------------
107
+ # ChainVerificationResult
108
+ # ---------------------------------------------------------------------------
109
+
110
+
111
+ @dataclass(frozen=True)
112
+ class ChainVerificationResult:
113
+ """Immutable result returned by :func:`verify_chain` and :meth:`AuditStream.verify`.
114
+
115
+ Attributes:
116
+ valid: ``True`` only if **all** signatures are valid, **no**
117
+ ``prev_id`` linkage gaps exist, and no tampering was
118
+ found.
119
+ first_tampered: The ``event_id`` of the first event whose signature
120
+ did not verify, or ``None`` if the chain is clean.
121
+ gaps: List of ``event_id`` values where the expected
122
+ ``prev_id`` linkage is broken — each entry represents
123
+ a potential deletion or reordering.
124
+ tampered_count: Total number of events with invalid signatures across
125
+ the entire chain.
126
+ tombstone_count: Number of ``AUDIT_TOMBSTONE`` events found in the chain.
127
+ tombstone_event_ids: Event IDs of all tombstone events.
128
+ """
129
+
130
+ valid: bool
131
+ first_tampered: str | None
132
+ gaps: list[str]
133
+ tampered_count: int
134
+ tombstone_count: int = 0
135
+ tombstone_event_ids: list[str] = field(default_factory=list)
136
+
137
+
138
+ # ---------------------------------------------------------------------------
139
+ # Internal crypto helpers
140
+ # ---------------------------------------------------------------------------
141
+
142
+
143
+ def _canonical_payload_bytes(payload: dict) -> bytes:
144
+ """Return compact, sorted UTF-8 JSON bytes for *payload*.
145
+
146
+ Uses ``sort_keys=True`` for determinism across Python versions and
147
+ ``separators=(",", ":")`` to eliminate optional whitespace.
148
+ """
149
+ return json.dumps(
150
+ payload, sort_keys=True, separators=(",", ":"), ensure_ascii=False
151
+ ).encode("utf-8")
152
+
153
+
154
+ def _compute_checksum(payload: dict) -> str:
155
+ """Return ``"sha256:<hex>"`` digest of the canonical payload JSON."""
156
+ digest = hashlib.sha256(_canonical_payload_bytes(payload)).hexdigest()
157
+ return f"sha256:{digest}"
158
+
159
+
160
+ def _compute_signature(
161
+ event_id: str,
162
+ checksum: str,
163
+ prev_id: str | None,
164
+ org_secret: str,
165
+ ) -> str:
166
+ """Return ``"hmac-sha256:<hex>"`` signature.
167
+
168
+ Message is ``"{event_id}|{checksum}|{prev_id or ''}"`` encoded as UTF-8.
169
+ """
170
+ msg = f"{event_id}|{checksum}|{prev_id or ''}"
171
+ mac = _hmac.new(
172
+ key=org_secret.encode("utf-8"),
173
+ msg=msg.encode("utf-8"),
174
+ digestmod=hashlib.sha256,
175
+ )
176
+ return f"hmac-sha256:{mac.hexdigest()}"
177
+
178
+
179
+ def _validate_secret(org_secret: str) -> None:
180
+ """Raise :exc:`~spanforge.exceptions.SigningError` if *org_secret* is empty or whitespace-only.
181
+
182
+ Security: the value of *org_secret* is **never** included in the error
183
+ message.
184
+ """
185
+ if not isinstance(org_secret, str) or not org_secret.strip():
186
+ raise SigningError("org_secret must be a non-empty, non-whitespace string")
187
+
188
+
189
+ _MIN_KEY_LENGTH = 32 # minimum bytes (256-bit) for production keys
190
+
191
+
192
+ def validate_key_strength(org_secret: str, *, min_length: int | None = None) -> list[str]:
193
+ """Check the signing key against strength requirements.
194
+
195
+ Returns a list of warning strings. Empty list = strong key.
196
+
197
+ Checks:
198
+ * Minimum length (default 32 chars / 256-bit, or ``SPANFORGE_SIGNING_KEY_MIN_BITS`` env var)
199
+ * Not all-same character
200
+ * Not a well-known placeholder
201
+ * Mixed character classes (upper, lower, digit, special)
202
+
203
+ Args:
204
+ org_secret: The signing key to check.
205
+ min_length: Minimum key length in characters. When ``None``, uses
206
+ ``SPANFORGE_SIGNING_KEY_MIN_BITS / 8`` or falls back to 32.
207
+
208
+ Returns:
209
+ List of human-readable warning strings. Empty if key is strong.
210
+ """
211
+ import os as _os # noqa: PLC0415
212
+
213
+ if min_length is None:
214
+ raw_bits = _os.environ.get("SPANFORGE_SIGNING_KEY_MIN_BITS")
215
+ if raw_bits is not None:
216
+ try:
217
+ min_length = max(1, int(raw_bits) // 8)
218
+ except (ValueError, TypeError):
219
+ min_length = _MIN_KEY_LENGTH
220
+ else:
221
+ min_length = _MIN_KEY_LENGTH
222
+
223
+ warnings: list[str] = []
224
+ if len(org_secret) < min_length:
225
+ warnings.append(f"Key length {len(org_secret)} < minimum {min_length} characters")
226
+ if len(set(org_secret)) == 1:
227
+ warnings.append("Key consists of a single repeated character")
228
+ _WEAK_KEYS = {
229
+ "spanforge-default",
230
+ "secret",
231
+ "password",
232
+ "changeme",
233
+ "test",
234
+ "key",
235
+ "demo",
236
+ }
237
+ if org_secret.lower().strip() in _WEAK_KEYS:
238
+ warnings.append("Key matches a well-known placeholder")
239
+ # Mixed character class check: at least 2 of (upper, lower, digit, special)
240
+ has_upper = any(c.isupper() for c in org_secret)
241
+ has_lower = any(c.islower() for c in org_secret)
242
+ has_digit = any(c.isdigit() for c in org_secret)
243
+ has_special = any(not c.isalnum() for c in org_secret)
244
+ char_classes = sum([has_upper, has_lower, has_digit, has_special])
245
+ if char_classes < 2:
246
+ warnings.append(
247
+ f"Key uses only {char_classes} character class(es); "
248
+ "recommend at least 2 (upper, lower, digit, special)"
249
+ )
250
+ return warnings
251
+
252
+
253
+ def check_key_expiry(expires_at: str | None) -> tuple[str, int]:
254
+ """Check the signing key expiry status.
255
+
256
+ Args:
257
+ expires_at: ISO-8601 datetime string, or ``None`` (no expiry).
258
+
259
+ Returns:
260
+ A tuple of ``(status, days)`` where *status* is one of:
261
+ - ``"no_expiry"`` — no expiration configured (days=0)
262
+ - ``"expired"`` — key has expired (days = days since expiry)
263
+ - ``"expiring_soon"`` — key expires within 7 days (days = days remaining)
264
+ - ``"valid"`` — key is valid (days = days remaining)
265
+ """
266
+ if expires_at is None:
267
+ return ("no_expiry", 0)
268
+ from datetime import datetime, timezone # noqa: PLC0415
269
+
270
+ try:
271
+ expiry = datetime.fromisoformat(expires_at)
272
+ if expiry.tzinfo is None:
273
+ expiry = expiry.replace(tzinfo=timezone.utc)
274
+ now = datetime.now(timezone.utc)
275
+ delta = expiry - now
276
+ days = delta.days
277
+ if days < 0:
278
+ return ("expired", abs(days))
279
+ if days <= 7:
280
+ return ("expiring_soon", days)
281
+ return ("valid", days)
282
+ except (ValueError, TypeError):
283
+ return ("no_expiry", 0)
284
+
285
+
286
+ def derive_key(
287
+ passphrase: str,
288
+ salt: bytes | None = None,
289
+ iterations: int = 600_000,
290
+ *,
291
+ context: str | None = None,
292
+ ) -> tuple[str, bytes]:
293
+ """Derive a signing key from a passphrase using PBKDF2-HMAC-SHA256.
294
+
295
+ Args:
296
+ passphrase: The human-memorable passphrase.
297
+ salt: 16-byte salt. A random salt is generated if ``None``.
298
+ iterations: PBKDF2 iteration count (default 600,000 per OWASP 2023).
299
+ context: Optional context string for environment isolation.
300
+ When provided, it is appended to the passphrase
301
+ (``passphrase + "|" + context``) before derivation,
302
+ ensuring the same passphrase yields different keys
303
+ for different environments (e.g. ``"staging"`` vs
304
+ ``"production"``).
305
+
306
+ Returns:
307
+ Tuple of ``(derived_key_hex, salt_bytes)``.
308
+
309
+ Example::
310
+
311
+ key, salt = derive_key("my strong passphrase", context="production")
312
+ # Store salt alongside the config; use key as org_secret.
313
+ """
314
+ import os as _os # noqa: PLC0415
315
+
316
+ if salt is None:
317
+ salt = _os.urandom(16)
318
+ effective_passphrase = passphrase
319
+ if context:
320
+ effective_passphrase = f"{passphrase}|{context}"
321
+ derived = hashlib.pbkdf2_hmac(
322
+ "sha256",
323
+ effective_passphrase.encode("utf-8"),
324
+ salt,
325
+ iterations,
326
+ dklen=32,
327
+ )
328
+ return derived.hex(), salt
329
+
330
+
331
+ # ---------------------------------------------------------------------------
332
+ # Public signing API
333
+ # ---------------------------------------------------------------------------
334
+
335
+
336
+ def sign(
337
+ event: Event,
338
+ org_secret: str,
339
+ prev_event: Event | None = None,
340
+ ) -> Event:
341
+ """Sign *event* and return a new event with ``checksum``, ``signature``, and ``prev_id`` set.
342
+
343
+ The original *event* is not mutated — a new
344
+ :class:`~spanforge.event.Event` instance is returned.
345
+
346
+ Signing steps::
347
+
348
+ checksum = sha256(canonical_payload_json)
349
+ sig_input = event_id + "|" + checksum + "|" + (prev_id or "")
350
+ signature = HMAC-SHA256(sig_input, org_secret)
351
+
352
+ Args:
353
+ event: The event to sign.
354
+ org_secret: HMAC signing key (non-empty string).
355
+ prev_event: The immediately preceding event in the audit chain, or
356
+ ``None`` if *event* is the first in the chain.
357
+
358
+ Returns:
359
+ A new :class:`~spanforge.event.Event` with ``checksum``, ``signature``,
360
+ and (if *prev_event* is given) ``prev_id`` populated.
361
+
362
+ Raises:
363
+ SigningError: If *org_secret* is empty or whitespace-only.
364
+
365
+ Example::
366
+
367
+ signed = sign(event, org_secret="my-key")
368
+ assert signed.checksum.startswith("sha256:")
369
+ assert signed.signature.startswith("hmac-sha256:")
370
+ """
371
+ # Deferred import to avoid circular dependency at module-load time.
372
+ from spanforge.event import Event # noqa: PLC0415
373
+
374
+ _validate_secret(org_secret)
375
+
376
+ # GA-01-B: Check key expiry before signing.
377
+ try:
378
+ from spanforge.config import get_config # noqa: PLC0415
379
+ _cfg = get_config()
380
+ if _cfg.signing_key_expires_at:
381
+ _expiry_status, _expiry_days = check_key_expiry(_cfg.signing_key_expires_at)
382
+ if _expiry_status == "expired":
383
+ raise SigningError(
384
+ f"Signing key expired {_expiry_days} day(s) ago — rotate key before signing"
385
+ )
386
+ except ImportError:
387
+ pass # config module not available — skip enforcement
388
+
389
+ # GA-04: Enforce require_org_id when configured.
390
+ try:
391
+ from spanforge.config import get_config as _get_config # noqa: PLC0415
392
+ if _get_config().require_org_id and not getattr(event, "org_id", None):
393
+ raise SigningError(
394
+ "require_org_id is enabled but event.org_id is None or empty"
395
+ )
396
+ except ImportError:
397
+ pass # config module not available — skip enforcement
398
+
399
+ prev_id: str | None = prev_event.event_id if prev_event is not None else None
400
+ checksum = _compute_checksum(dict(event.payload))
401
+ signature = _compute_signature(event.event_id, checksum, prev_id, org_secret)
402
+
403
+ return Event(
404
+ schema_version=event.schema_version,
405
+ event_id=event.event_id,
406
+ event_type=event.event_type,
407
+ timestamp=event.timestamp,
408
+ source=event.source,
409
+ payload=dict(event.payload),
410
+ trace_id=event.trace_id,
411
+ span_id=event.span_id,
412
+ parent_span_id=event.parent_span_id,
413
+ org_id=event.org_id,
414
+ team_id=event.team_id,
415
+ actor_id=event.actor_id,
416
+ session_id=event.session_id,
417
+ tags=event.tags,
418
+ checksum=checksum,
419
+ signature=signature,
420
+ prev_id=prev_id,
421
+ )
422
+
423
+
424
+ def verify(event: Event, org_secret: str) -> bool:
425
+ """Verify the checksum and HMAC signature of a single signed event.
426
+
427
+ Uses :func:`hmac.compare_digest` for both comparisons to guard against
428
+ timing-based side-channel attacks.
429
+
430
+ Args:
431
+ event: The event to verify.
432
+ org_secret: HMAC signing key used when the event was signed.
433
+
434
+ Returns:
435
+ ``True`` if both the checksum and signature are cryptographically
436
+ valid. ``False`` if either fails (tampered payload, wrong key, or
437
+ missing checksum/signature).
438
+
439
+ Raises:
440
+ SigningError: If *org_secret* is empty or whitespace-only.
441
+
442
+ Note:
443
+ This function deliberately returns ``False`` rather than raising for
444
+ tampered events — :func:`verify_chain` calls it in a loop and needs
445
+ to accumulate all failures. For the strict raising variant see
446
+ :func:`assert_verified`.
447
+
448
+ Example::
449
+
450
+ if not verify(event, org_secret="my-key"):
451
+ raise RuntimeError(f"Tampered event: {event.event_id}")
452
+ """
453
+ _validate_secret(org_secret)
454
+
455
+ if event.checksum is None or event.signature is None:
456
+ return False
457
+
458
+ expected_checksum = _compute_checksum(dict(event.payload))
459
+ if not _hmac.compare_digest(event.checksum, expected_checksum):
460
+ return False
461
+
462
+ expected_signature = _compute_signature(
463
+ event.event_id, event.checksum, event.prev_id, org_secret
464
+ )
465
+ return _hmac.compare_digest(event.signature, expected_signature)
466
+
467
+
468
+ def assert_verified(event: Event, org_secret: str) -> None:
469
+ """Assert that *event* passes cryptographic verification.
470
+
471
+ Strict variant of :func:`verify` that raises instead of returning
472
+ ``False``.
473
+
474
+ Args:
475
+ event: The event to verify.
476
+ org_secret: HMAC signing key used when the event was signed.
477
+
478
+ Raises:
479
+ VerificationError: If :func:`verify` returns ``False``.
480
+ SigningError: If *org_secret* is empty or whitespace-only.
481
+
482
+ Example::
483
+
484
+ assert_verified(event, org_secret="my-key") # raises on tamper
485
+ """
486
+ if not verify(event, org_secret):
487
+ raise VerificationError(event_id=event.event_id)
488
+
489
+
490
+ def _check_event_signature(
491
+ event: "Event",
492
+ current_secret: str,
493
+ first_tampered: str | None,
494
+ tampered_count: int,
495
+ ) -> tuple[str | None, int]:
496
+ """Check signature validity; returns updated (first_tampered, tampered_count)."""
497
+ if not verify(event, current_secret):
498
+ tampered_count += 1
499
+ if first_tampered is None:
500
+ first_tampered = event.event_id
501
+ return first_tampered, tampered_count
502
+
503
+
504
+ def _check_chain_linkage(
505
+ event: "Event",
506
+ i: int,
507
+ event_list: list["Event"],
508
+ gaps: list[str],
509
+ ) -> None:
510
+ """Check chain linkage; appends to gaps if a gap is detected."""
511
+ if i == 0:
512
+ if event.prev_id is not None:
513
+ gaps.append(event.event_id)
514
+ else:
515
+ expected_prev = event_list[i - 1].event_id
516
+ if event.prev_id != expected_prev:
517
+ gaps.append(event.event_id)
518
+
519
+
520
+ def verify_chain(
521
+ events: Sequence[Event],
522
+ org_secret: str,
523
+ key_map: dict[str, str] | None = None,
524
+ *,
525
+ key_resolver: KeyResolver | None = None,
526
+ default_key: str | None = None,
527
+ ) -> ChainVerificationResult:
528
+ """Verify an entire ordered sequence of signed events as an audit chain.
529
+
530
+ Performs three checks per event:
531
+
532
+ 1. **Signature validity** — recomputes checksum and HMAC; flags mismatches.
533
+ 2. **Chain linkage** — ``events[n].prev_id == events[n-1].event_id``.
534
+ 3. **Head integrity** — ``events[0].prev_id`` must be ``None`` (no missing
535
+ predecessor); non-``None`` signals an undetected gap at the head.
536
+
537
+ Key rotation
538
+ ~~~~~~~~~~~~
539
+ Pass ``key_map`` to handle chains that span a key rotation. The dict maps
540
+ a rotation event's ``event_id`` to the new secret that takes effect
541
+ **after** that event is verified::
542
+
543
+ result = verify_chain(events, org_secret="old-key",
544
+ key_map={"<rotation_event_id>": "new-key"})
545
+
546
+ Multi-tenant verification
547
+ ~~~~~~~~~~~~~~~~~~~~~~~~~
548
+ Pass ``key_resolver`` to verify chains containing events from different
549
+ orgs. For each event with an ``org_id``, the resolver provides the
550
+ corresponding secret. Events without ``org_id`` use *default_key* (falls
551
+ back to *org_secret*).
552
+
553
+ Args:
554
+ events: Ordered sequence of events (earliest first). May be
555
+ empty — returns ``valid=True`` with no failures.
556
+ org_secret: HMAC signing key for the first chain segment.
557
+ key_map: Optional ``{rotation_event_id: new_secret}`` dict
558
+ enabling multi-segment verification after key rotation.
559
+ key_resolver: Optional :class:`KeyResolver` for per-org key resolution.
560
+ default_key: Fallback key for events without ``org_id`` when using
561
+ *key_resolver*. Defaults to *org_secret*.
562
+
563
+ Returns:
564
+ A :class:`ChainVerificationResult` with ``valid``, ``first_tampered``,
565
+ ``gaps``, and ``tampered_count``.
566
+
567
+ Raises:
568
+ SigningError: If *org_secret* (or any value in *key_map*) is empty.
569
+
570
+ Example::
571
+
572
+ result = verify_chain(signed_events, org_secret="my-key")
573
+ if not result.valid:
574
+ print(f"First tampered: {result.first_tampered}")
575
+ print(f"Gaps (deleted events): {result.gaps}")
576
+ """
577
+ _validate_secret(org_secret)
578
+ if key_map:
579
+ for new_secret in key_map.values():
580
+ _validate_secret(new_secret)
581
+
582
+ _default_key = default_key if default_key is not None else org_secret
583
+ current_secret = org_secret
584
+ km = key_map or {}
585
+
586
+ first_tampered: str | None = None
587
+ gaps: list[str] = []
588
+ tampered_count = 0
589
+ tombstone_count = 0
590
+ tombstone_event_ids: list[str] = []
591
+
592
+ _TOMBSTONE_TYPE = "llm.audit.tombstone"
593
+
594
+ event_list = list(events)
595
+
596
+ for i, event in enumerate(event_list):
597
+ # Determine the secret to use for this event
598
+ verify_secret = current_secret
599
+ if key_resolver is not None:
600
+ oid = getattr(event, "org_id", None)
601
+ if oid:
602
+ try:
603
+ verify_secret = key_resolver.resolve(oid)
604
+ except Exception: # noqa: BLE001
605
+ # If resolver fails, use default key
606
+ verify_secret = _default_key
607
+ else:
608
+ verify_secret = _default_key
609
+
610
+ first_tampered, tampered_count = _check_event_signature(
611
+ event, verify_secret, first_tampered, tampered_count
612
+ )
613
+ _check_chain_linkage(event, i, event_list, gaps)
614
+ if event.event_id in km:
615
+ current_secret = km[event.event_id]
616
+
617
+ # Track tombstone events
618
+ evt_type = str(event.event_type) if event.event_type else ""
619
+ if evt_type == _TOMBSTONE_TYPE:
620
+ tombstone_count += 1
621
+ tombstone_event_ids.append(event.event_id)
622
+
623
+ valid = tampered_count == 0 and len(gaps) == 0
624
+ return ChainVerificationResult(
625
+ valid=valid,
626
+ first_tampered=first_tampered,
627
+ gaps=gaps,
628
+ tampered_count=tampered_count,
629
+ tombstone_count=tombstone_count,
630
+ tombstone_event_ids=tombstone_event_ids,
631
+ )
632
+
633
+
634
+ # ---------------------------------------------------------------------------
635
+ # Subject mention helper
636
+ # ---------------------------------------------------------------------------
637
+
638
+
639
+ def _event_mentions_subject(event: "Event", subject_id: str) -> bool:
640
+ """Return ``True`` if *event* contains *subject_id* in searchable fields.
641
+
642
+ Scans ``actor_id``, ``session_id``, and flattened payload values.
643
+ Uses **exact** match to avoid false positives from substring overlap.
644
+ """
645
+ if event.actor_id and event.actor_id == subject_id:
646
+ return True
647
+ if event.session_id and event.session_id == subject_id:
648
+ return True
649
+ # Scan payload values (shallow — one level deep)
650
+ payload = event.payload or {}
651
+ for v in payload.values():
652
+ if isinstance(v, str) and v == subject_id:
653
+ return True
654
+ return False
655
+
656
+
657
+ # ---------------------------------------------------------------------------
658
+ # GA-04: Multi-tenant Key Resolvers
659
+ # ---------------------------------------------------------------------------
660
+
661
+
662
+ @_runtime_checkable
663
+ class KeyResolver(_Protocol):
664
+ """Protocol for resolving signing keys per-org in multi-tenant setups.
665
+
666
+ Implementations must return a non-empty string secret for the given
667
+ ``org_id``. Raise :exc:`~spanforge.exceptions.SigningError` if the key
668
+ cannot be resolved.
669
+ """
670
+
671
+ def resolve(self, org_id: str) -> str:
672
+ """Return the signing secret for *org_id*."""
673
+ ... # pragma: no cover
674
+
675
+
676
+ class StaticKeyResolver:
677
+ """Resolves every org to the same static key.
678
+
679
+ Useful for single-tenant deployments or testing.
680
+ """
681
+
682
+ __slots__ = ("_secret",)
683
+
684
+ def __init__(self, secret: str) -> None:
685
+ _validate_secret(secret)
686
+ self._secret = secret
687
+
688
+ def resolve(self, org_id: str) -> str:
689
+ return self._secret
690
+
691
+
692
+ class EnvKeyResolver:
693
+ """Resolves the signing key from an environment variable.
694
+
695
+ The env var name is derived from the org_id:
696
+ ``{prefix}{org_id.upper().replace('-', '_')}``.
697
+
698
+ Args:
699
+ prefix: Env var prefix (default ``"SPANFORGE_KEY_"``).
700
+ """
701
+
702
+ __slots__ = ("_prefix",)
703
+
704
+ def __init__(self, prefix: str = "SPANFORGE_KEY_") -> None:
705
+ self._prefix = prefix
706
+
707
+ def resolve(self, org_id: str) -> str:
708
+ import os as _os # noqa: PLC0415
709
+
710
+ var_name = self._prefix + org_id.upper().replace("-", "_")
711
+ secret = _os.environ.get(var_name, "")
712
+ if not secret.strip():
713
+ raise SigningError(
714
+ f"No signing key found for org '{org_id}' "
715
+ f"(expected env var {var_name})"
716
+ )
717
+ return secret
718
+
719
+
720
+ class DictKeyResolver:
721
+ """Resolves signing keys from an in-memory dictionary.
722
+
723
+ Args:
724
+ keys: Mapping of ``org_id`` → signing secret.
725
+ """
726
+
727
+ __slots__ = ("_keys",)
728
+
729
+ def __init__(self, keys: dict[str, str]) -> None:
730
+ for org_id, secret in keys.items():
731
+ _validate_secret(secret)
732
+ self._keys = dict(keys)
733
+
734
+ def resolve(self, org_id: str) -> str:
735
+ secret = self._keys.get(org_id)
736
+ if not secret:
737
+ raise SigningError(f"No signing key found for org '{org_id}' in key map")
738
+ return secret
739
+
740
+
741
+ # ---------------------------------------------------------------------------
742
+ # AuditStream
743
+ # ---------------------------------------------------------------------------
744
+
745
+
746
+ class AuditStream:
747
+ """Tamper-evident HMAC-signed audit chain stream.
748
+
749
+ Sequential event stream that HMAC-signs every appended event and links
750
+ them via ``prev_id``, forming a tamper-evident audit chain.
751
+
752
+ The signing secret is **never** exposed in :func:`repr`, :func:`str`, or
753
+ any exception message.
754
+
755
+ Args:
756
+ org_secret: HMAC signing key (non-empty string).
757
+ source: The ``source`` field used for auto-generated audit events
758
+ such as key-rotation events. Must follow the
759
+ ``tool-name@x.y.z`` format accepted by
760
+ :class:`~spanforge.event.Event`.
761
+
762
+ Raises:
763
+ SigningError: If *org_secret* is empty or whitespace-only.
764
+
765
+ Example::
766
+
767
+ stream = AuditStream(org_secret="corp-key", source="audit-daemon@1.0.0")
768
+ for event in events:
769
+ stream.append(event)
770
+ stream.rotate_key("corp-key-v2", metadata={"reason": "scheduled"})
771
+ result = stream.verify()
772
+ assert result.valid
773
+ """
774
+
775
+ __slots__ = ("_events", "_initial_secret", "_key_map", "_key_resolver", "_lock", "_org_secret", "_require_org_id", "_source")
776
+
777
+ def __init__(
778
+ self,
779
+ org_secret: str,
780
+ source: str,
781
+ *,
782
+ key_resolver: KeyResolver | None = None,
783
+ require_org_id: bool = False,
784
+ ) -> None:
785
+ _validate_secret(org_secret)
786
+ object.__setattr__(self, "_initial_secret", org_secret)
787
+ object.__setattr__(self, "_org_secret", org_secret)
788
+ object.__setattr__(self, "_source", source)
789
+ object.__setattr__(self, "_events", [])
790
+ # maps rotation_event_id → new_secret for verify()
791
+ object.__setattr__(self, "_key_map", {})
792
+ object.__setattr__(self, "_key_resolver", key_resolver)
793
+ object.__setattr__(self, "_require_org_id", require_org_id)
794
+ # Protects _events list and _org_secret during concurrent appends / rotations.
795
+ object.__setattr__(self, "_lock", __import__("threading").RLock())
796
+
797
+ def __setattr__(self, name: str, value: object) -> None: # type: ignore[override]
798
+ """Block external attribute mutation.
799
+
800
+ Internal code uses :func:`object.__setattr__` directly.
801
+ """
802
+ raise AttributeError(
803
+ f"AuditStream is immutable externally — attribute '{name}' cannot be set. "
804
+ "Use append() or rotate_key() to modify the stream."
805
+ )
806
+
807
+ def __repr__(self) -> str:
808
+ """Safe repr that never exposes the signing secret."""
809
+ return f"<AuditStream events={len(self._events)}>" # type: ignore[arg-type]
810
+
811
+ def __str__(self) -> str:
812
+ return f"<AuditStream events={len(self._events)}>" # type: ignore[arg-type]
813
+
814
+ def __len__(self) -> int:
815
+ return len(self._events) # type: ignore[arg-type]
816
+
817
+ # ------------------------------------------------------------------
818
+ # Properties
819
+ # ------------------------------------------------------------------
820
+
821
+ @property
822
+ def events(self) -> list[Event]:
823
+ """A read-only copy of all signed events in the stream.
824
+
825
+ Returns a new list each call so callers cannot mutate the internal
826
+ state.
827
+ """
828
+ return list(self._events) # type: ignore[arg-type]
829
+
830
+ # ------------------------------------------------------------------
831
+ # Mutation methods (guarded)
832
+ # ------------------------------------------------------------------
833
+
834
+ def append(self, event: Event) -> Event:
835
+ """Sign *event*, link it to the chain, append, and return the signed event.
836
+
837
+ The given *event* is not mutated. A new
838
+ :class:`~spanforge.event.Event` with ``checksum``, ``signature``, and
839
+ ``prev_id`` set is returned **and** stored in the stream.
840
+
841
+ Args:
842
+ event: The unsigned (or partially signed) event to add.
843
+
844
+ Returns:
845
+ The freshly signed event with full chain linkage.
846
+
847
+ Raises:
848
+ SigningError: If the current signing key is somehow invalid
849
+ (should not happen if the stream was constructed
850
+ correctly).
851
+ """
852
+ with self._lock: # type: ignore[attr-defined]
853
+ # GA-04-C: Enforce require_org_id at the stream level.
854
+ if self._require_org_id and not getattr(event, "org_id", None): # type: ignore[attr-defined]
855
+ raise SigningError(
856
+ "require_org_id is enabled but event.org_id is None or empty"
857
+ )
858
+ # If a key_resolver is configured and the event has an org_id, use it.
859
+ resolver: KeyResolver | None = self._key_resolver # type: ignore[assignment]
860
+ secret: str = self._org_secret # type: ignore[assignment]
861
+ if resolver is not None and getattr(event, "org_id", None):
862
+ secret = resolver.resolve(event.org_id)
863
+ events_list: list[Event] = self._events # type: ignore[assignment]
864
+ prev_event: Event | None = events_list[-1] if events_list else None
865
+ # GA-06-A: Sign and append under the same lock to guarantee
866
+ # prev_event linkage is never stale under concurrent appends.
867
+ signed = sign(event, secret, prev_event=prev_event)
868
+ events_list.append(signed)
869
+ return signed
870
+
871
+ def rotate_key(
872
+ self,
873
+ new_secret: str,
874
+ metadata: dict[str, str] | None = None,
875
+ ) -> Event:
876
+ """Rotate the signing key: append a key-rotation event and switch keys.
877
+
878
+ The key-rotation event is signed with the **current** key, ensuring
879
+ continuity of the chain at the rotation boundary. All events appended
880
+ after this call are signed with *new_secret*.
881
+
882
+ Args:
883
+ new_secret: The new HMAC signing key (non-empty string).
884
+ metadata: Optional ``str → str`` payload fields for the
885
+ rotation event (e.g. ``{"reason": "scheduled",
886
+ "rotated_by": "ops-team"}``).
887
+
888
+ Returns:
889
+ The signed key-rotation :class:`~spanforge.event.Event`.
890
+
891
+ Raises:
892
+ SigningError: If *new_secret* is empty or whitespace-only.
893
+
894
+ Example::
895
+
896
+ stream.rotate_key("new-secret-v2", metadata={"reason": "annual"})
897
+ """
898
+ # Deferred imports to avoid circular dependency at module-load time.
899
+ from spanforge.event import Event # noqa: PLC0415
900
+ from spanforge.types import EventType # noqa: PLC0415
901
+
902
+ _validate_secret(new_secret)
903
+
904
+ # Include SHA-256 hash of new secret so verifiers can detect key substitution.
905
+ new_secret_hash = hashlib.sha256(new_secret.encode("utf-8")).hexdigest()
906
+ payload: dict[str, str] = {
907
+ "rotation_marker": "true",
908
+ "new_secret_hash": new_secret_hash,
909
+ }
910
+ if metadata:
911
+ payload.update(metadata)
912
+ # Ensure rotated_by is always present for audit trail completeness.
913
+ if "rotated_by" not in payload:
914
+ payload["rotated_by"] = "unknown"
915
+
916
+ rotation_event = Event(
917
+ event_type=EventType.AUDIT_KEY_ROTATED,
918
+ source=self._source, # type: ignore[arg-type]
919
+ payload=payload,
920
+ )
921
+
922
+ # Hold the lock for the entire sign + key-switch so that no other
923
+ # thread can append events between the rotation event and the first
924
+ # event signed with the new key.
925
+ with self._lock: # type: ignore[attr-defined]
926
+ events_list: list[Event] = self._events # type: ignore[assignment]
927
+ prev_event: Event | None = events_list[-1] if events_list else None
928
+ signed_rotation = sign(rotation_event, self._org_secret, prev_event=prev_event) # type: ignore[arg-type]
929
+ events_list.append(signed_rotation)
930
+
931
+ # After this event_id, use new_secret for subsequent events.
932
+ # Both key_map update and secret switch happen inside the lock
933
+ # so other threads see a consistent state.
934
+ key_map: dict[str, str] = self._key_map # type: ignore[assignment]
935
+ key_map[signed_rotation.event_id] = new_secret
936
+ object.__setattr__(self, "_org_secret", new_secret)
937
+
938
+ return signed_rotation
939
+
940
+ # ------------------------------------------------------------------
941
+ # Verification
942
+ # ------------------------------------------------------------------
943
+
944
+ def verify(self) -> ChainVerificationResult:
945
+ """Verify the entire chain, respecting any key-rotation boundaries.
946
+
947
+ Internally calls :func:`verify_chain` with the initial secret and the
948
+ accumulated ``key_map`` from all :meth:`rotate_key` calls.
949
+
950
+ Returns:
951
+ A :class:`ChainVerificationResult` reflecting the state of the
952
+ complete chain.
953
+ """
954
+ key_map: dict[str, str] = self._key_map # type: ignore[assignment]
955
+ return verify_chain(
956
+ self._events, # type: ignore[arg-type]
957
+ org_secret=self._initial_secret, # type: ignore[arg-type]
958
+ key_map=dict(key_map) if key_map else None,
959
+ )
960
+
961
+ def erase_subject(
962
+ self,
963
+ subject_id: str,
964
+ *,
965
+ erased_by: str = "unknown",
966
+ reason: str = "GDPR Art.17 right to erasure",
967
+ request_ref: str = "",
968
+ ) -> list[Event]:
969
+ """Replace all events mentioning *subject_id* with TOMBSTONE events.
970
+
971
+ Scans the chain for events whose ``actor_id``, ``session_id``, or
972
+ payload values contain *subject_id*. Each matched event is replaced
973
+ in-place with an ``AUDIT_TOMBSTONE`` event that preserves the original
974
+ ``event_id`` and chain linkage (``prev_id``, ``checksum``,
975
+ ``signature``) so the chain remains verifiable.
976
+
977
+ The tombstone payload records the original ``event_type`` and reason.
978
+ **No PII is retained** in the tombstone.
979
+
980
+ Args:
981
+ subject_id: The data-subject identifier to erase (e.g. user ID,
982
+ email, or session token).
983
+ erased_by: Identity of the operator performing the erasure.
984
+ reason: Free-text reason recorded in each tombstone.
985
+ request_ref: External reference for the erasure request
986
+ (e.g. ticket ID or GDPR request number).
987
+
988
+ Returns:
989
+ List of the tombstone :class:`Event` instances that replaced
990
+ original events. Empty if no matches were found.
991
+ """
992
+ from spanforge.event import Event as _Event # noqa: PLC0415
993
+ from spanforge.types import EventType # noqa: PLC0415
994
+
995
+ import json as _json # noqa: PLC0415
996
+
997
+ tombstones: list[Event] = []
998
+ with self._lock: # type: ignore[attr-defined]
999
+ events_list: list[Event] = self._events # type: ignore[assignment]
1000
+ for idx, event in enumerate(events_list):
1001
+ if not _event_mentions_subject(event, subject_id):
1002
+ continue
1003
+
1004
+ # Build tombstone payload — no PII
1005
+ tombstone_payload = {
1006
+ "erased_subject_id_hash": hashlib.sha256(
1007
+ subject_id.encode("utf-8")
1008
+ ).hexdigest(),
1009
+ "original_event_type": str(event.event_type),
1010
+ "erased_by": erased_by,
1011
+ "reason": reason,
1012
+ }
1013
+ if request_ref:
1014
+ tombstone_payload["erasure_request_ref"] = request_ref
1015
+
1016
+ tombstone = _Event(
1017
+ schema_version=event.schema_version,
1018
+ event_id=event.event_id,
1019
+ event_type=EventType.AUDIT_TOMBSTONE.value,
1020
+ timestamp=event.timestamp,
1021
+ source=self._source, # type: ignore[arg-type]
1022
+ payload=tombstone_payload,
1023
+ trace_id=event.trace_id,
1024
+ span_id=event.span_id,
1025
+ parent_span_id=event.parent_span_id,
1026
+ org_id=event.org_id,
1027
+ team_id=event.team_id,
1028
+ actor_id=None,
1029
+ session_id=None,
1030
+ tags=event.tags,
1031
+ checksum=event.checksum,
1032
+ signature=event.signature,
1033
+ prev_id=event.prev_id,
1034
+ )
1035
+
1036
+ # Re-sign to maintain chain integrity
1037
+ prev_event: Event | None = events_list[idx - 1] if idx > 0 else None
1038
+ signed_tombstone = sign(tombstone, self._org_secret, prev_event=prev_event) # type: ignore[arg-type]
1039
+
1040
+ events_list[idx] = signed_tombstone
1041
+ tombstones.append(signed_tombstone)
1042
+
1043
+ # Re-sign subsequent event to maintain linkage
1044
+ if idx + 1 < len(events_list):
1045
+ next_evt = events_list[idx + 1]
1046
+ re_signed = sign(next_evt, self._org_secret, prev_event=signed_tombstone) # type: ignore[arg-type]
1047
+ events_list[idx + 1] = re_signed
1048
+
1049
+ return tombstones
1050
+
1051
+
1052
+ # ---------------------------------------------------------------------------
1053
+ # GA-06: AsyncAuditStream — asyncio-native audit chain
1054
+ # ---------------------------------------------------------------------------
1055
+
1056
+
1057
+ class AsyncAuditStream:
1058
+ """Asyncio-native tamper-evident HMAC-signed audit chain.
1059
+
1060
+ Mirrors the API of :class:`AuditStream` but uses :class:`asyncio.Lock`
1061
+ instead of :class:`threading.RLock`, making it safe for ``async def``
1062
+ code paths without blocking the event loop.
1063
+
1064
+ Args:
1065
+ org_secret: HMAC signing key (non-empty string).
1066
+ source: The ``source`` field for auto-generated audit events.
1067
+ key_resolver: Optional :class:`KeyResolver` for multi-tenant setups.
1068
+
1069
+ Example::
1070
+
1071
+ stream = AsyncAuditStream(org_secret="key", source="svc@1.0.0")
1072
+ signed = await stream.append(event)
1073
+ result = await stream.verify()
1074
+ """
1075
+
1076
+ __slots__ = ("_events", "_initial_secret", "_key_map", "_key_resolver", "_lock", "_org_secret", "_source")
1077
+
1078
+ def __init__(
1079
+ self,
1080
+ org_secret: str,
1081
+ source: str,
1082
+ *,
1083
+ key_resolver: KeyResolver | None = None,
1084
+ ) -> None:
1085
+ import asyncio # noqa: PLC0415
1086
+
1087
+ _validate_secret(org_secret)
1088
+ self._initial_secret = org_secret
1089
+ self._org_secret = org_secret
1090
+ self._source = source
1091
+ self._events: list[Any] = []
1092
+ self._key_map: dict[str, str] = {}
1093
+ self._key_resolver = key_resolver
1094
+ self._lock = asyncio.Lock()
1095
+
1096
+ def __repr__(self) -> str:
1097
+ return f"<AsyncAuditStream events={len(self._events)}>"
1098
+
1099
+ def __len__(self) -> int:
1100
+ return len(self._events)
1101
+
1102
+ @property
1103
+ def events(self) -> list[Any]:
1104
+ """A read-only copy of all signed events."""
1105
+ return list(self._events)
1106
+
1107
+ async def append(self, event: Any) -> Any: # noqa: ANN401
1108
+ """Sign *event*, link it to the chain, and return the signed event."""
1109
+ async with self._lock:
1110
+ resolver = self._key_resolver
1111
+ secret = self._org_secret
1112
+ if resolver is not None and getattr(event, "org_id", None):
1113
+ secret = resolver.resolve(event.org_id)
1114
+ prev_event = self._events[-1] if self._events else None
1115
+ signed = sign(event, secret, prev_event=prev_event)
1116
+ self._events.append(signed)
1117
+ return signed
1118
+
1119
+ async def rotate_key(
1120
+ self,
1121
+ new_secret: str,
1122
+ metadata: dict[str, str] | None = None,
1123
+ ) -> Any: # noqa: ANN401
1124
+ """Rotate the signing key (async version)."""
1125
+ from spanforge.event import Event, EventType # noqa: PLC0415
1126
+
1127
+ _validate_secret(new_secret)
1128
+ async with self._lock:
1129
+ rotation_payload = {"action": "key_rotation"}
1130
+ if metadata:
1131
+ rotation_payload.update(metadata)
1132
+
1133
+ rotation_event = Event(
1134
+ event_type=EventType.AUDIT_KEY_ROTATED,
1135
+ source=self._source,
1136
+ payload=rotation_payload,
1137
+ )
1138
+ prev_event = self._events[-1] if self._events else None
1139
+ signed_rotation = sign(rotation_event, self._org_secret, prev_event=prev_event)
1140
+ self._events.append(signed_rotation)
1141
+ self._key_map[signed_rotation.event_id] = new_secret
1142
+ self._org_secret = new_secret
1143
+ return signed_rotation
1144
+
1145
+ async def verify(self) -> ChainVerificationResult:
1146
+ """Verify the full chain (async wrapper)."""
1147
+ async with self._lock:
1148
+ return verify_chain(
1149
+ self._events,
1150
+ self._initial_secret,
1151
+ key_map=self._key_map,
1152
+ )