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
@@ -0,0 +1,282 @@
1
+ """spanforge.model_registry — Model lifecycle tracking for AI compliance.
2
+
3
+ Provides a thread-safe in-memory registry of ML/AI models with lifecycle
4
+ transitions (active → deprecated → retired). Each mutation emits an
5
+ auditable event into the HMAC chain.
6
+
7
+ Emits ``model_registry.registered``, ``model_registry.deprecated``,
8
+ ``model_registry.retired`` events via :func:`emit_rfc_event`.
9
+
10
+ Usage::
11
+
12
+ from spanforge.model_registry import ModelRegistry, ModelRegistryEntry
13
+
14
+ registry = ModelRegistry()
15
+ entry = registry.register(
16
+ model_id="gpt-4o-2024-05",
17
+ name="GPT-4o",
18
+ version="2024-05",
19
+ risk_tier="high",
20
+ owner="platform-team",
21
+ purpose="customer support agent",
22
+ )
23
+ registry.deprecate("gpt-4o-2024-05", reason="Replaced by gpt-4o-2024-08")
24
+ registry.retire("gpt-4o-2024-05")
25
+ """
26
+
27
+ from __future__ import annotations
28
+
29
+ import json
30
+ import threading
31
+ from dataclasses import asdict, dataclass, field
32
+ from pathlib import Path
33
+ from typing import Any, Literal
34
+
35
+ __all__ = [
36
+ "ModelRegistry",
37
+ "ModelRegistryEntry",
38
+ "register_model",
39
+ "deprecate_model",
40
+ "retire_model",
41
+ "list_models",
42
+ "get_model",
43
+ ]
44
+
45
+ _VALID_RISK_TIERS = frozenset({"low", "medium", "high", "critical"})
46
+ _VALID_STATUSES = frozenset({"active", "deprecated", "retired"})
47
+
48
+
49
+ @dataclass
50
+ class ModelRegistryEntry:
51
+ """A single model registered for compliance tracking."""
52
+
53
+ model_id: str
54
+ name: str
55
+ version: str
56
+ risk_tier: Literal["low", "medium", "high", "critical"]
57
+ owner: str
58
+ purpose: str
59
+ status: Literal["active", "deprecated", "retired"] = "active"
60
+ deployment_date: str | None = None
61
+ decommission_date: str | None = None
62
+ metadata: dict[str, Any] = field(default_factory=dict)
63
+
64
+ def __post_init__(self) -> None:
65
+ if not self.model_id:
66
+ raise ValueError("ModelRegistryEntry.model_id must be non-empty")
67
+ if not self.name:
68
+ raise ValueError("ModelRegistryEntry.name must be non-empty")
69
+ if not self.version:
70
+ raise ValueError("ModelRegistryEntry.version must be non-empty")
71
+ if self.risk_tier not in _VALID_RISK_TIERS:
72
+ raise ValueError(
73
+ f"ModelRegistryEntry.risk_tier must be one of {sorted(_VALID_RISK_TIERS)}"
74
+ )
75
+ if not self.owner:
76
+ raise ValueError("ModelRegistryEntry.owner must be non-empty")
77
+ if not self.purpose:
78
+ raise ValueError("ModelRegistryEntry.purpose must be non-empty")
79
+ if self.status not in _VALID_STATUSES:
80
+ raise ValueError(
81
+ f"ModelRegistryEntry.status must be one of {sorted(_VALID_STATUSES)}"
82
+ )
83
+
84
+ def to_dict(self) -> dict[str, Any]:
85
+ return asdict(self)
86
+
87
+ @classmethod
88
+ def from_dict(cls, data: dict[str, Any]) -> ModelRegistryEntry:
89
+ return cls(**{k: v for k, v in data.items() if k in cls.__dataclass_fields__})
90
+
91
+
92
+ class ModelRegistry:
93
+ """Thread-safe in-memory model registry with lifecycle transitions.
94
+
95
+ Each mutation emits an audit event into the HMAC chain.
96
+ Optionally, the registry can persist to/from a JSON file.
97
+ """
98
+
99
+ def __init__(self, *, auto_emit: bool = True) -> None:
100
+ self._lock = threading.Lock()
101
+ self._models: dict[str, ModelRegistryEntry] = {}
102
+ self._auto_emit = auto_emit
103
+
104
+ def register(
105
+ self,
106
+ model_id: str,
107
+ name: str,
108
+ version: str,
109
+ risk_tier: Literal["low", "medium", "high", "critical"],
110
+ owner: str,
111
+ purpose: str,
112
+ *,
113
+ deployment_date: str | None = None,
114
+ metadata: dict[str, Any] | None = None,
115
+ ) -> ModelRegistryEntry:
116
+ """Register a new model and emit ``model_registry.registered``."""
117
+ entry = ModelRegistryEntry(
118
+ model_id=model_id,
119
+ name=name,
120
+ version=version,
121
+ risk_tier=risk_tier,
122
+ owner=owner,
123
+ purpose=purpose,
124
+ status="active",
125
+ deployment_date=deployment_date or self._now(),
126
+ metadata=metadata or {},
127
+ )
128
+ with self._lock:
129
+ if model_id in self._models:
130
+ raise ValueError(
131
+ f"Model {model_id!r} already registered. "
132
+ "Use a unique model_id or retire the existing entry first."
133
+ )
134
+ self._models[model_id] = entry
135
+
136
+ if self._auto_emit:
137
+ self._emit("registered", entry)
138
+ return entry
139
+
140
+ def deprecate(self, model_id: str, *, reason: str = "") -> ModelRegistryEntry:
141
+ """Mark a model as deprecated and emit ``model_registry.deprecated``."""
142
+ with self._lock:
143
+ entry = self._models.get(model_id)
144
+ if entry is None:
145
+ raise KeyError(f"Model {model_id!r} not found in registry")
146
+ if entry.status == "retired":
147
+ raise ValueError(f"Model {model_id!r} is already retired")
148
+ entry.status = "deprecated"
149
+ if reason:
150
+ entry.metadata["deprecation_reason"] = reason
151
+
152
+ if self._auto_emit:
153
+ self._emit("deprecated", entry)
154
+ return entry
155
+
156
+ def retire(self, model_id: str) -> ModelRegistryEntry:
157
+ """Move a model to retired status and emit ``model_registry.retired``."""
158
+ with self._lock:
159
+ entry = self._models.get(model_id)
160
+ if entry is None:
161
+ raise KeyError(f"Model {model_id!r} not found in registry")
162
+ entry.status = "retired"
163
+ entry.decommission_date = self._now()
164
+
165
+ if self._auto_emit:
166
+ self._emit("retired", entry)
167
+ return entry
168
+
169
+ def get(self, model_id: str) -> ModelRegistryEntry | None:
170
+ """Look up a model entry by ID."""
171
+ with self._lock:
172
+ return self._models.get(model_id)
173
+
174
+ def list_all(self) -> list[ModelRegistryEntry]:
175
+ """Return all registered models."""
176
+ with self._lock:
177
+ return list(self._models.values())
178
+
179
+ def list_active(self) -> list[ModelRegistryEntry]:
180
+ """Return only models with ``status == 'active'``."""
181
+ with self._lock:
182
+ return [m for m in self._models.values() if m.status == "active"]
183
+
184
+ def clear(self) -> None:
185
+ """Remove all entries (for testing)."""
186
+ with self._lock:
187
+ self._models.clear()
188
+
189
+ # -----------------------------------------------------------------------
190
+ # Persistence
191
+ # -----------------------------------------------------------------------
192
+
193
+ def save(self, path: str | Path) -> None:
194
+ """Persist registry to a JSON file."""
195
+ path = Path(path)
196
+ path.parent.mkdir(parents=True, exist_ok=True)
197
+ with self._lock:
198
+ data = [e.to_dict() for e in self._models.values()]
199
+ path.write_text(json.dumps(data, indent=2, default=str), encoding="utf-8")
200
+
201
+ def load(self, path: str | Path) -> None:
202
+ """Load registry from a JSON file, replacing current entries."""
203
+ path = Path(path)
204
+ raw = json.loads(path.read_text(encoding="utf-8"))
205
+ entries = [ModelRegistryEntry.from_dict(d) for d in raw]
206
+ with self._lock:
207
+ self._models = {e.model_id: e for e in entries}
208
+
209
+ # -----------------------------------------------------------------------
210
+ # Internal helpers
211
+ # -----------------------------------------------------------------------
212
+
213
+ @staticmethod
214
+ def _now() -> str:
215
+ import datetime # noqa: PLC0415
216
+ return datetime.datetime.now(datetime.timezone.utc).strftime(
217
+ "%Y-%m-%dT%H:%M:%S.%fZ"
218
+ )
219
+
220
+ @staticmethod
221
+ def _emit(action: str, entry: ModelRegistryEntry) -> None:
222
+ """Emit a model registry event into the HMAC audit chain."""
223
+ try:
224
+ from spanforge._stream import emit_rfc_event # noqa: PLC0415
225
+ from spanforge.types import EventType # noqa: PLC0415
226
+
227
+ _action_to_event = {
228
+ "registered": EventType.MODEL_REGISTERED,
229
+ "deprecated": EventType.MODEL_DEPRECATED,
230
+ "retired": EventType.MODEL_RETIRED,
231
+ }
232
+ et = _action_to_event.get(action)
233
+ if et is None:
234
+ return
235
+ try:
236
+ emit_rfc_event(et, entry.to_dict())
237
+ except Exception: # noqa: BLE001
238
+ pass
239
+ except ImportError:
240
+ pass
241
+
242
+
243
+ # ---------------------------------------------------------------------------
244
+ # Module-level singleton & convenience functions
245
+ # ---------------------------------------------------------------------------
246
+
247
+ _registry = ModelRegistry()
248
+
249
+
250
+ def register_model(
251
+ model_id: str,
252
+ name: str,
253
+ version: str,
254
+ risk_tier: Literal["low", "medium", "high", "critical"],
255
+ owner: str,
256
+ purpose: str,
257
+ **kwargs: Any,
258
+ ) -> ModelRegistryEntry:
259
+ """Register a model via the module-level :class:`ModelRegistry`."""
260
+ return _registry.register(
261
+ model_id, name, version, risk_tier, owner, purpose, **kwargs
262
+ )
263
+
264
+
265
+ def deprecate_model(model_id: str, **kwargs: Any) -> ModelRegistryEntry:
266
+ """Deprecate a model via the module-level :class:`ModelRegistry`."""
267
+ return _registry.deprecate(model_id, **kwargs)
268
+
269
+
270
+ def retire_model(model_id: str) -> ModelRegistryEntry:
271
+ """Retire a model via the module-level :class:`ModelRegistry`."""
272
+ return _registry.retire(model_id)
273
+
274
+
275
+ def list_models() -> list[ModelRegistryEntry]:
276
+ """List all models via the module-level :class:`ModelRegistry`."""
277
+ return _registry.list_all()
278
+
279
+
280
+ def get_model(model_id: str) -> ModelRegistryEntry | None:
281
+ """Get a model via the module-level :class:`ModelRegistry`."""
282
+ return _registry.get(model_id)
spanforge/models.py ADDED
@@ -0,0 +1,407 @@
1
+ """Pydantic v2 model layer for spanforge events.
2
+
3
+ This module provides Pydantic v2 models that mirror the :class:`~spanforge.event.Event`
4
+ envelope with strict field-level validation and bidirectional conversion.
5
+
6
+ The model layer is **optional** — it requires ``pydantic>=2.7`` which is not a
7
+ core dependency. Install it with::
8
+
9
+ pip install "spanforge[pydantic]"
10
+
11
+ Design goals
12
+ ------------
13
+ * All field validation is equivalent to :meth:`~spanforge.event.Event.validate`,
14
+ giving callers a familiar API while leveraging Pydantic's declarative style.
15
+ * :class:`EventModel` is immutable (``frozen=True``).
16
+ * :meth:`EventModel.from_event` and :meth:`EventModel.to_event` provide lossless
17
+ round-trips.
18
+ * :meth:`EventModel.model_json_schema` exports a full JSON Schema (for Phase 5
19
+ schema publication).
20
+
21
+ Example::
22
+
23
+ from spanforge import Event, EventType
24
+ from spanforge.models import EventModel
25
+
26
+ event = Event(
27
+ event_type=EventType.TRACE_SPAN_COMPLETED,
28
+ source="llm-trace@0.3.1",
29
+ payload={"status": "ok"},
30
+ )
31
+ model = EventModel.from_event(event)
32
+ print(model.model_json_schema())
33
+ restored = model.to_event()
34
+ assert restored.event_id == event.event_id
35
+ """
36
+
37
+ from __future__ import annotations
38
+
39
+ import re
40
+ from typing import Any
41
+
42
+ try:
43
+ from pydantic import BaseModel, ConfigDict, Field, field_validator
44
+ from pydantic import ValidationError as _PydanticValidationError # noqa: F401
45
+ except ImportError as _import_err: # pragma: no cover
46
+ raise ImportError(
47
+ "pydantic>=2.7 is required for spanforge.models. "
48
+ "Install it: pip install \"spanforge[pydantic]\""
49
+ ) from _import_err
50
+
51
+ from spanforge.event import SCHEMA_VERSION, Event, Tags
52
+ from spanforge.types import EVENT_TYPE_PATTERN
53
+ from spanforge.ulid import validate as _validate_ulid
54
+
55
+ __all__ = ["EventModel", "TagsModel"]
56
+
57
+ # ---------------------------------------------------------------------------
58
+ # Validation patterns (must stay in sync with spanforge/event.py)
59
+ # ---------------------------------------------------------------------------
60
+
61
+ _SEMVER_RE: re.Pattern[str] = re.compile(
62
+ r"^\d+\.\d+(?:\.\d+)?(?:[.-][a-zA-Z0-9.]+)?$"
63
+ )
64
+ _SOURCE_RE: re.Pattern[str] = re.compile(
65
+ r"^[a-z][a-z0-9\-]*@\d+\.\d+\.\d+$"
66
+ )
67
+ _TIMESTAMP_RE: re.Pattern[str] = re.compile(
68
+ r"^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?Z$"
69
+ )
70
+ _TRACE_ID_RE: re.Pattern[str] = re.compile(r"^[0-9a-f]{32}$")
71
+ _SPAN_ID_RE: re.Pattern[str] = re.compile(r"^[0-9a-f]{16}$")
72
+ _EVENT_TYPE_RE: re.Pattern[str] = re.compile(EVENT_TYPE_PATTERN)
73
+
74
+
75
+ # ---------------------------------------------------------------------------
76
+ # TagsModel
77
+ # ---------------------------------------------------------------------------
78
+
79
+
80
+ class TagsModel(BaseModel):
81
+ """Pydantic model for event tags.
82
+
83
+ Allows arbitrary ``str → str`` key-value pairs as extra fields. All
84
+ values must be strings; non-string values are rejected by Pydantic.
85
+
86
+ Example::
87
+
88
+ tags = TagsModel(env="production", model="gpt-4o")
89
+ tags.model_dump() # {"env": "production", "model": "gpt-4o"}
90
+ """
91
+
92
+ model_config = ConfigDict(frozen=True, extra="allow")
93
+
94
+ @classmethod
95
+ def from_tags(cls, tags: Tags) -> TagsModel:
96
+ """Construct from a :class:`~spanforge.event.Tags` instance.
97
+
98
+ Args:
99
+ tags: A :class:`~spanforge.event.Tags` instance.
100
+
101
+ Returns:
102
+ A corresponding :class:`TagsModel`.
103
+ """
104
+ return cls(**dict(tags))
105
+
106
+ def to_tags(self) -> Tags:
107
+ """Convert back to a :class:`~spanforge.event.Tags` instance.
108
+
109
+ Returns:
110
+ A new :class:`~spanforge.event.Tags` with the same key-value pairs.
111
+ """
112
+ return Tags(**self.model_dump())
113
+
114
+
115
+ # ---------------------------------------------------------------------------
116
+ # EventModel
117
+ # ---------------------------------------------------------------------------
118
+
119
+
120
+ class EventModel(BaseModel):
121
+ """Pydantic v2 model for the spanforge event envelope.
122
+
123
+ Each field carries a Pydantic ``Field`` description and is validated by a
124
+ ``@field_validator``. The model is frozen (immutable after construction).
125
+
126
+ Validation rules are equivalent to those enforced by
127
+ :meth:`~spanforge.event.Event.validate`, so ``EventModel.from_event(event)``
128
+ succeeds for any event that passes :meth:`~spanforge.event.Event.validate`.
129
+
130
+ Args:
131
+ schema_version: Schema version string (e.g. ``"1.0"``).
132
+ event_id: 26-character ULID.
133
+ event_type: Namespaced event type (e.g. ``"llm.trace.span.completed"``).
134
+ timestamp: UTC ISO-8601 timestamp (e.g. ``"2026-03-01T12:00:00.000000Z"``).
135
+ source: Tool name + version (e.g. ``"llm-trace@0.3.1"``).
136
+ payload: Non-empty dict of event-type-specific data.
137
+ trace_id: Optional 32-char hex OpenTelemetry trace ID.
138
+ span_id: Optional 16-char hex OpenTelemetry span ID.
139
+ parent_span_id: Optional 16-char hex parent span ID.
140
+ org_id: Optional organisation identifier.
141
+ team_id: Optional team identifier.
142
+ actor_id: Optional user/service identifier.
143
+ session_id: Optional session/conversation identifier.
144
+ tags: Optional :class:`TagsModel` with arbitrary metadata.
145
+ checksum: Optional SHA-256 payload checksum.
146
+ signature: Optional HMAC-SHA256 audit chain signature.
147
+ prev_id: Optional ULID of preceding event in audit chain.
148
+
149
+ Example::
150
+
151
+ from spanforge.models import EventModel
152
+
153
+ model = EventModel(
154
+ event_id="01ARYZ3NDEKTSV4RRFFQ69G5FAV",
155
+ event_type="llm.trace.span.completed",
156
+ timestamp="2026-03-01T12:00:00.000000Z",
157
+ source="llm-trace@0.3.1",
158
+ payload={"status": "ok"},
159
+ )
160
+ """
161
+
162
+ model_config = ConfigDict(frozen=True, populate_by_name=True)
163
+
164
+ schema_version: str = Field(
165
+ default=SCHEMA_VERSION,
166
+ description="Schema version string, e.g. '1.0'.",
167
+ )
168
+ event_id: str = Field(
169
+ description="26-character Crockford Base32 ULID event identifier.",
170
+ )
171
+ event_type: str = Field(
172
+ description="Namespaced event type, e.g. 'llm.trace.span.completed'.",
173
+ )
174
+ timestamp: str = Field(
175
+ description="UTC ISO-8601 timestamp, e.g. '2026-03-01T12:00:00.000000Z'.",
176
+ )
177
+ source: str = Field(
178
+ description="Source tool and version, e.g. 'llm-trace@0.3.1'.",
179
+ )
180
+ payload: dict[str, Any] = Field(
181
+ description="Non-empty dict of event-type-specific data.",
182
+ )
183
+ trace_id: str | None = Field(
184
+ default=None,
185
+ description="OpenTelemetry trace ID — 32 lowercase hex characters.",
186
+ )
187
+ span_id: str | None = Field(
188
+ default=None,
189
+ description="OpenTelemetry span ID — 16 lowercase hex characters.",
190
+ )
191
+ parent_span_id: str | None = Field(
192
+ default=None,
193
+ description="Parent span ID — 16 lowercase hex characters.",
194
+ )
195
+ org_id: str | None = Field(
196
+ default=None,
197
+ description="Organisation identifier (non-empty string).",
198
+ )
199
+ team_id: str | None = Field(
200
+ default=None,
201
+ description="Team identifier within the organisation (non-empty string).",
202
+ )
203
+ actor_id: str | None = Field(
204
+ default=None,
205
+ description="User or service actor identifier (non-empty string).",
206
+ )
207
+ session_id: str | None = Field(
208
+ default=None,
209
+ description="Session or conversation identifier (non-empty string).",
210
+ )
211
+ tags: TagsModel | None = Field(
212
+ default=None,
213
+ description="Arbitrary string key-value metadata tags.",
214
+ )
215
+ checksum: str | None = Field(
216
+ default=None,
217
+ description="SHA-256 payload checksum (prefixed 'sha256:').",
218
+ )
219
+ signature: str | None = Field(
220
+ default=None,
221
+ description="HMAC-SHA256 audit chain signature (set by spanforge.signing).",
222
+ )
223
+ prev_id: str | None = Field(
224
+ default=None,
225
+ description="ULID of the preceding event in the tamper-evident audit chain.",
226
+ )
227
+
228
+ # ------------------------------------------------------------------
229
+ # Field validators
230
+ # ------------------------------------------------------------------
231
+
232
+ @field_validator("schema_version")
233
+ @classmethod
234
+ def _check_schema_version(cls, v: str) -> str:
235
+ if not _SEMVER_RE.match(v):
236
+ raise ValueError(
237
+ f"schema_version must match SemVer pattern (e.g. '1.0'), got {v!r}"
238
+ )
239
+ return v
240
+
241
+ @field_validator("event_id")
242
+ @classmethod
243
+ def _check_event_id(cls, v: str) -> str:
244
+ if not _validate_ulid(v):
245
+ raise ValueError(
246
+ "event_id must be a valid 26-character ULID (Crockford Base32)"
247
+ )
248
+ return v
249
+
250
+ @field_validator("event_type")
251
+ @classmethod
252
+ def _check_event_type(cls, v: str) -> str:
253
+ if not _EVENT_TYPE_RE.match(v):
254
+ raise ValueError(
255
+ "event_type must follow 'llm.<namespace>.<entity>.<action>' "
256
+ "or 'x.<company>.<…>' pattern"
257
+ )
258
+ return v
259
+
260
+ @field_validator("timestamp")
261
+ @classmethod
262
+ def _check_timestamp(cls, v: str) -> str:
263
+ if not _TIMESTAMP_RE.match(v):
264
+ raise ValueError(
265
+ "timestamp must be a UTC ISO-8601 string ending in 'Z', "
266
+ f"e.g. '2026-03-01T12:00:00.000000Z', got {v!r}"
267
+ )
268
+ return v
269
+
270
+ @field_validator("source")
271
+ @classmethod
272
+ def _check_source(cls, v: str) -> str:
273
+ if not _SOURCE_RE.match(v):
274
+ raise ValueError(
275
+ "source must match 'tool-name@semver' pattern (full 3-part semver), "
276
+ f"e.g. 'llm-trace@0.3.1', got {v!r}"
277
+ )
278
+ return v
279
+
280
+ @field_validator("payload")
281
+ @classmethod
282
+ def _check_payload(cls, v: dict[str, Any]) -> dict[str, Any]:
283
+ if not v:
284
+ raise ValueError("payload must be a non-empty dict")
285
+ return v
286
+
287
+ @field_validator("trace_id")
288
+ @classmethod
289
+ def _check_trace_id(cls, v: str | None) -> str | None:
290
+ if v is not None and not _TRACE_ID_RE.match(v):
291
+ raise ValueError(
292
+ "trace_id must be exactly 32 lowercase hex characters"
293
+ )
294
+ return v
295
+
296
+ @field_validator("span_id", "parent_span_id")
297
+ @classmethod
298
+ def _check_span_id(cls, v: str | None) -> str | None:
299
+ if v is not None and not _SPAN_ID_RE.match(v):
300
+ raise ValueError(
301
+ "span_id / parent_span_id must be exactly 16 lowercase hex characters"
302
+ )
303
+ return v
304
+
305
+ @field_validator("org_id", "team_id", "actor_id", "session_id")
306
+ @classmethod
307
+ def _check_string_id(cls, v: str | None) -> str | None:
308
+ if v is not None and not v.strip():
309
+ raise ValueError("org_id / team_id / actor_id / session_id must be non-empty")
310
+ return v
311
+
312
+ @field_validator("prev_id")
313
+ @classmethod
314
+ def _check_prev_id(cls, v: str | None) -> str | None:
315
+ if v is not None and not _validate_ulid(v):
316
+ raise ValueError(
317
+ "prev_id must be a valid 26-character ULID (Crockford Base32)"
318
+ )
319
+ return v
320
+
321
+ # ------------------------------------------------------------------
322
+ # Conversion helpers
323
+ # ------------------------------------------------------------------
324
+
325
+ @classmethod
326
+ def from_event(cls, event: Event) -> EventModel:
327
+ """Construct an :class:`EventModel` from an :class:`~spanforge.event.Event`.
328
+
329
+ Args:
330
+ event: A validated or unvalidated :class:`~spanforge.event.Event`.
331
+
332
+ Returns:
333
+ A new :class:`EventModel` with all fields populated.
334
+
335
+ Raises:
336
+ pydantic.ValidationError: If the event contains invalid field values.
337
+
338
+ Example::
339
+
340
+ model = EventModel.from_event(event)
341
+ """
342
+ tags_model: TagsModel | None = (
343
+ TagsModel.from_tags(event.tags) if event.tags is not None else None
344
+ )
345
+ return cls(
346
+ schema_version=event.schema_version,
347
+ event_id=event.event_id,
348
+ event_type=event.event_type,
349
+ timestamp=event.timestamp,
350
+ source=event.source,
351
+ payload=dict(event.payload),
352
+ trace_id=event.trace_id,
353
+ span_id=event.span_id,
354
+ parent_span_id=event.parent_span_id,
355
+ org_id=event.org_id,
356
+ team_id=event.team_id,
357
+ actor_id=event.actor_id,
358
+ session_id=event.session_id,
359
+ tags=tags_model,
360
+ checksum=event.checksum,
361
+ signature=event.signature,
362
+ prev_id=event.prev_id,
363
+ )
364
+
365
+ def to_event(self) -> Event:
366
+ """Convert this model back to an :class:`~spanforge.event.Event`.
367
+
368
+ The returned event has the same field values as this model. Call
369
+ :meth:`~spanforge.event.Event.validate` if you want to re-run all
370
+ built-in validators (they are equivalent to those already applied by
371
+ Pydantic during construction of this model).
372
+
373
+ Returns:
374
+ A new :class:`~spanforge.event.Event` instance.
375
+
376
+ Example::
377
+
378
+ event = model.to_event()
379
+ assert event.event_id == model.event_id
380
+ """
381
+ tags: Tags | None = (
382
+ self.tags.to_tags() if self.tags is not None else None
383
+ )
384
+ kwargs: dict[str, Any] = {
385
+ k: v
386
+ for k, v in {
387
+ "schema_version": self.schema_version,
388
+ "event_id": self.event_id,
389
+ "event_type": self.event_type,
390
+ "timestamp": self.timestamp,
391
+ "source": self.source,
392
+ "payload": dict(self.payload),
393
+ "trace_id": self.trace_id,
394
+ "span_id": self.span_id,
395
+ "parent_span_id": self.parent_span_id,
396
+ "org_id": self.org_id,
397
+ "team_id": self.team_id,
398
+ "actor_id": self.actor_id,
399
+ "session_id": self.session_id,
400
+ "tags": tags,
401
+ "checksum": self.checksum,
402
+ "signature": self.signature,
403
+ "prev_id": self.prev_id,
404
+ }.items()
405
+ if v is not None
406
+ }
407
+ return Event(**kwargs)