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/event.py ADDED
@@ -0,0 +1,1064 @@
1
+ """Core event envelope for spanforge v0.1.
2
+
3
+ Every event emitted by every tool in the LLM Developer Toolkit must conform to
4
+ the :class:`Event` class defined here. This is the canonical Python
5
+ representation of the JSON event envelope specified in the Enterprise Product
6
+ Specification §3.1.
7
+
8
+ Design goals
9
+ ------------
10
+ * **Zero external dependencies** — only :mod:`datetime`, :mod:`json`,
11
+ :mod:`hashlib`, and :mod:`re` from the standard library.
12
+ * **``__slots__``** on all hot-path classes for minimal heap allocation.
13
+ * **Deterministic serialisation** — the same :class:`Event` always produces
14
+ the same JSON string; critical for HMAC signing.
15
+ * **Typed validation** — every validation failure is a
16
+ :class:`~spanforge.exceptions.SchemaValidationError` with the field name,
17
+ received value, and a clear reason; never a bare :exc:`ValueError`.
18
+ * **Immutability after creation** — envelope fields are read-only via
19
+ properties; mutation is limited to the ``sign()`` method (Phase 3) which sets
20
+ ``checksum``, ``signature``, and ``prev_id``.
21
+
22
+ Serialisation contract
23
+ ----------------------
24
+ ``Event.to_json()`` produces canonical JSON with:
25
+
26
+ * Keys sorted alphabetically at every nesting level.
27
+ * ``None`` values **omitted** (reduces wire size; missing key == ``null``).
28
+ * :class:`datetime.datetime` values formatted as ``"YYYY-MM-DDTHH:MM:SS.ffffffZ"``.
29
+ * :class:`~spanforge.types.EventType` values serialised as their string value.
30
+ * :class:`Tags` serialised as a JSON object with sorted string keys.
31
+ """
32
+
33
+ from __future__ import annotations
34
+
35
+ import datetime
36
+ import hashlib
37
+ import json
38
+ import re
39
+ import sys
40
+ from types import MappingProxyType
41
+ from typing import TYPE_CHECKING, Any, Final
42
+
43
+ from spanforge.exceptions import (
44
+ DeserializationError,
45
+ EventTypeError,
46
+ SchemaValidationError,
47
+ SerializationError,
48
+ )
49
+ from spanforge.types import _EVENT_TYPE_RE, EventType, is_registered, validate_custom
50
+ from spanforge.ulid import generate as _generate_ulid
51
+ from spanforge.ulid import validate as _validate_ulid
52
+
53
+ if TYPE_CHECKING:
54
+ from collections.abc import ItemsView, Iterator, KeysView, Mapping, ValuesView
55
+
56
+ __all__ = ["SCHEMA_VERSION", "Event", "Tags"]
57
+
58
+ # ---------------------------------------------------------------------------
59
+ # Constants
60
+ # ---------------------------------------------------------------------------
61
+
62
+ SCHEMA_VERSION: Final[str] = "2.0"
63
+
64
+ #: Accepted schema versions for backward-compatibility (RFC-0001 §15.5).
65
+ _ACCEPTED_SCHEMA_VERSIONS: Final[frozenset[str]] = frozenset({"1.0", "2.0"})
66
+
67
+ _MUST_BE_STRING: Final[str] = "must be a string"
68
+
69
+ #: ``service-name@semver`` — e.g. ``my-agent@1.2.0`` or ``MyAgent@1.0.0``
70
+ #: RFC-0001 §5.1: first char letter, then letters/digits/._- ; ``@`` ; semver
71
+ _SOURCE_PATTERN: Final[re.Pattern[str]] = re.compile(
72
+ r"^[a-zA-Z][a-zA-Z0-9._\-]*@\d+\.\d+\.\d+(?:[.\-][a-zA-Z0-9.]+)?$"
73
+ )
74
+ #: ISO-8601 UTC datetime — EXACTLY 6 decimal places (RFC-0001 §6.1)
75
+ _TIMESTAMP_PATTERN: Final[re.Pattern[str]] = re.compile(
76
+ r"^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{6}Z$"
77
+ )
78
+ #: Schema version — accepts major.minor or major.minor.patch (+ optional prerelease)
79
+ _SEMVER_PATTERN: Final[re.Pattern[str]] = re.compile(r"^\d+\.\d+(?:\.\d+)?(?:[.-][a-zA-Z0-9.]+)?$")
80
+ #: Trace ID — exactly 32 lowercase hex characters
81
+ _TRACE_ID_PATTERN: Final[re.Pattern[str]] = re.compile(r"^[0-9a-f]{32}$")
82
+ #: Span ID — exactly 16 lowercase hex characters
83
+ _SPAN_ID_PATTERN: Final[re.Pattern[str]] = re.compile(r"^[0-9a-f]{16}$")
84
+
85
+
86
+ # ---------------------------------------------------------------------------
87
+ # Tags
88
+ # ---------------------------------------------------------------------------
89
+
90
+
91
+ class Tags:
92
+ """Immutable key-value tag container for :class:`Event`.
93
+
94
+ Tags are arbitrary string key→value pairs that enrich an event with
95
+ contextual metadata (e.g. ``env``, ``model``, ``region``).
96
+
97
+ All keys and values must be non-empty strings. The container is
98
+ immutable after construction to prevent accidental mutation of a live event.
99
+
100
+ Example::
101
+
102
+ tags = Tags(env="production", model="gpt-4o", region="us-east-1")
103
+ tags["env"] # "production"
104
+ "model" in tags # True
105
+ dict(tags) # {"env": "production", "model": "gpt-4o", ...}
106
+ """
107
+
108
+ _data: dict[str, str]
109
+
110
+ __slots__ = ("_data",)
111
+
112
+ def __init__(self, **kwargs: str) -> None:
113
+ """Create a new :class:`Tags` instance.
114
+
115
+ Args:
116
+ **kwargs: Arbitrary string key=value pairs.
117
+
118
+ Raises:
119
+ SchemaValidationError: If any key or value is not a non-empty string.
120
+ """
121
+ for key, value in kwargs.items():
122
+ if not isinstance(key, str) or not key:
123
+ raise SchemaValidationError(
124
+ field=f"tags.{key!r}",
125
+ received=key,
126
+ reason="tag key must be a non-empty string",
127
+ )
128
+ if not isinstance(value, str) or not value:
129
+ raise SchemaValidationError(
130
+ field=f"tags.{key}",
131
+ received=value,
132
+ reason="tag value must be a non-empty string",
133
+ )
134
+ # Store as a sorted immutable snapshot.
135
+ object.__setattr__(self, "_data", dict(sorted(kwargs.items())))
136
+
137
+ # ------------------------------------------------------------------
138
+ # Read-only mapping interface
139
+ # ------------------------------------------------------------------
140
+
141
+ def __getitem__(self, key: str) -> str:
142
+ return self._data[key]
143
+
144
+ def __contains__(self, key: object) -> bool:
145
+ return key in self._data
146
+
147
+ def __iter__(self) -> Iterator[str]:
148
+ return iter(self._data)
149
+
150
+ def __len__(self) -> int:
151
+ return len(self._data)
152
+
153
+ def __setattr__(self, name: str, value: object) -> None:
154
+ raise AttributeError("Tags is immutable; create a new instance instead")
155
+
156
+ def __eq__(self, other: object) -> bool:
157
+ if isinstance(other, Tags):
158
+ return self._data == other._data
159
+ if isinstance(other, dict):
160
+ return self._data == other
161
+ return NotImplemented
162
+
163
+ __hash__: None = None # type: ignore[assignment] # Tags is unhashable
164
+
165
+ def __repr__(self) -> str:
166
+ kv = ", ".join(f"{k}={v!r}" for k, v in self._data.items())
167
+ return f"Tags({kv})"
168
+
169
+ def get(self, key: str, default: str | None = None) -> str | None:
170
+ """Return the value for *key*, or *default* if not present."""
171
+ return self._data.get(key, default)
172
+
173
+ def keys(self) -> KeysView[str]:
174
+ """Return tag keys."""
175
+ return self._data.keys()
176
+
177
+ def values(self) -> ValuesView[str]:
178
+ """Return tag values."""
179
+ return self._data.values()
180
+
181
+ def items(self) -> ItemsView[str, str]:
182
+ """Return (key, value) pairs."""
183
+ return self._data.items()
184
+
185
+ def to_dict(self) -> dict[str, str]:
186
+ """Return a plain :class:`dict` copy of the tags."""
187
+ return dict(self._data)
188
+
189
+
190
+ # ---------------------------------------------------------------------------
191
+ # Event
192
+ # ---------------------------------------------------------------------------
193
+
194
+
195
+ class Event:
196
+ """The canonical event envelope for the LLM Developer Toolkit.
197
+
198
+ Every tool in the ecosystem creates events that conform to this class.
199
+ The envelope is designed to map cleanly to OTLP spans/log records (Phase 4)
200
+ and to carry optional HMAC signing for audit integrity (Phase 3).
201
+
202
+ Quick start
203
+ -----------
204
+ ::
205
+
206
+ from spanforge import Event, EventType, Tags
207
+
208
+ event = Event(
209
+ event_type=EventType.TRACE_SPAN_COMPLETED,
210
+ source="llm-trace@0.3.1",
211
+ payload={"span_name": "run_agent", "status": "ok"},
212
+ tags=Tags(env="production", model="gpt-4o"),
213
+ )
214
+ event.validate()
215
+ json_str = event.to_json()
216
+
217
+ Required fields
218
+ ---------------
219
+ * ``schema_version`` — automatically set to ``"2.0"``
220
+ * ``event_id`` — auto-generated ULID if not supplied
221
+ * ``event_type`` — namespaced string or :class:`~spanforge.types.EventType`
222
+ * ``timestamp`` — UTC ISO-8601; auto-generated if not supplied
223
+ * ``source`` — ``"tool-name@semver"``
224
+ * ``payload`` — tool-specific data (non-empty dict)
225
+
226
+ All other fields are optional.
227
+
228
+ Thread safety
229
+ -------------
230
+ :class:`Event` instances are **not** thread-safe for concurrent mutation.
231
+ Create separate instances per thread/task.
232
+ """
233
+
234
+ _schema_version: str
235
+ _event_id: str
236
+ _event_type: str
237
+ _timestamp: str
238
+ _source: str
239
+ _payload: dict[str, Any]
240
+ _trace_id: str | None
241
+ _span_id: str | None
242
+ _parent_span_id: str | None
243
+ _org_id: str | None
244
+ _team_id: str | None
245
+ _actor_id: str | None
246
+ _session_id: str | None
247
+ _tags: Tags | None
248
+ _checksum: str | None
249
+ _signature: str | None
250
+ _prev_id: str | None
251
+ _unknown_fields: dict[str, Any]
252
+
253
+ __slots__ = (
254
+ "_actor_id",
255
+ # Integrity (mutated by sign() in Phase 3)
256
+ "_checksum",
257
+ "_event_id",
258
+ "_event_type",
259
+ # Context
260
+ "_org_id",
261
+ "_parent_span_id",
262
+ "_payload",
263
+ "_prev_id",
264
+ "_schema_version",
265
+ "_session_id",
266
+ "_signature",
267
+ "_source",
268
+ "_span_id",
269
+ # Tags
270
+ "_tags",
271
+ "_team_id",
272
+ "_timestamp",
273
+ # Tracing
274
+ "_trace_id",
275
+ # GA-05-D: Unknown fields preserved during deserialization
276
+ "_unknown_fields",
277
+ )
278
+
279
+ def __init__( # NOSONAR
280
+ self,
281
+ *,
282
+ event_type: str | EventType,
283
+ source: str,
284
+ payload: dict[str, Any],
285
+ schema_version: str = SCHEMA_VERSION,
286
+ event_id: str | None = None,
287
+ timestamp: str | None = None,
288
+ trace_id: str | None = None,
289
+ span_id: str | None = None,
290
+ parent_span_id: str | None = None,
291
+ org_id: str | None = None,
292
+ team_id: str | None = None,
293
+ actor_id: str | None = None,
294
+ session_id: str | None = None,
295
+ tags: Tags | None = None,
296
+ checksum: str | None = None,
297
+ signature: str | None = None,
298
+ prev_id: str | None = None,
299
+ ) -> None:
300
+ """Create a new :class:`Event`.
301
+
302
+ Auto-generated fields
303
+ ---------------------
304
+ * ``event_id`` — a new ULID is generated if not provided.
305
+ * ``timestamp`` — current UTC time is used if not provided.
306
+
307
+ Args:
308
+ event_type: Namespaced event type (string or :class:`EventType`).
309
+ source: Emitting tool in ``"name@semver"`` format.
310
+ payload: Tool-specific event data (non-empty dict).
311
+ schema_version: Schema version string. Defaults to current ``"2.0"``.
312
+ event_id: ULID. Auto-generated if omitted.
313
+ timestamp: UTC ISO-8601 string. Set to ``utcnow()`` if omitted.
314
+ trace_id: 32-hex-char OpenTelemetry trace ID.
315
+ span_id: 16-hex-char OpenTelemetry span ID.
316
+ parent_span_id: 16-hex-char parent span ID.
317
+ org_id: Organisation identifier.
318
+ team_id: Team identifier.
319
+ actor_id: User or service-account identifier.
320
+ session_id: Session identifier grouping related events.
321
+ tags: :class:`Tags` instance with string metadata.
322
+ checksum: SHA-256 payload checksum (set by ``sign()``).
323
+ signature: HMAC-SHA256 chain signature (set by ``sign()``).
324
+ prev_id: ULID of previous event in audit chain (set by ``sign()``).
325
+
326
+ Raises:
327
+ SchemaValidationError: If any supplied field has an invalid type or
328
+ value. The exception carries :attr:`~SchemaValidationError.field`
329
+ and :attr:`~SchemaValidationError.reason`.
330
+ """
331
+ # --- Required fields -------------------------------------------
332
+ object.__setattr__(self, "_schema_version", schema_version)
333
+ object.__setattr__(
334
+ self, "_event_id", event_id if event_id is not None else _generate_ulid()
335
+ )
336
+ # .value gives the canonical string for EventType members; str() is
337
+ # unreliable across Python versions for mixed str+Enum types.
338
+ _et_value: str = event_type.value if isinstance(event_type, EventType) else str(event_type)
339
+ object.__setattr__(self, "_event_type", _et_value)
340
+ object.__setattr__(
341
+ self,
342
+ "_timestamp",
343
+ timestamp if timestamp is not None else _utcnow_iso(),
344
+ )
345
+ object.__setattr__(self, "_source", source)
346
+ # When the event is already signed (checksum set), freeze the payload
347
+ # so that any post-signing mutation raises TypeError immediately.
348
+ # For unsigned events, store a shallow dict copy to protect against
349
+ # external-reference mutations invalidating the payload at sign time.
350
+ # Guard: only convert when payload is a dict/Mapping — non-dict payloads
351
+ # are stored as-is so that validate() can report the type error cleanly.
352
+ if isinstance(payload, dict):
353
+ if checksum is not None:
354
+ object.__setattr__(self, "_payload", MappingProxyType(dict(payload)))
355
+ else:
356
+ object.__setattr__(self, "_payload", dict(payload))
357
+ elif isinstance(payload, MappingProxyType):
358
+ # Accept MappingProxyType directly (e.g. from to_dict round-trips)
359
+ object.__setattr__(self, "_payload", payload if checksum is not None else dict(payload))
360
+ else:
361
+ # Non-dict payload: store as-is; validate() will raise SchemaValidationError
362
+ object.__setattr__(self, "_payload", payload)
363
+
364
+ # --- Tracing ---------------------------------------------------
365
+ object.__setattr__(self, "_trace_id", trace_id)
366
+ object.__setattr__(self, "_span_id", span_id)
367
+ object.__setattr__(self, "_parent_span_id", parent_span_id)
368
+
369
+ # --- Context ---------------------------------------------------
370
+ object.__setattr__(self, "_org_id", org_id)
371
+ object.__setattr__(self, "_team_id", team_id)
372
+ object.__setattr__(self, "_actor_id", actor_id)
373
+ object.__setattr__(self, "_session_id", session_id)
374
+
375
+ # --- Tags / Integrity ------------------------------------------
376
+ object.__setattr__(self, "_tags", tags)
377
+ object.__setattr__(self, "_checksum", checksum)
378
+ object.__setattr__(self, "_signature", signature)
379
+ object.__setattr__(self, "_prev_id", prev_id)
380
+
381
+ # --- Unknown fields (GA-05-D: forward-compat) ---------------------
382
+ object.__setattr__(self, "_unknown_fields", {})
383
+
384
+ # ------------------------------------------------------------------
385
+ # Read-only properties
386
+ # ------------------------------------------------------------------
387
+
388
+ @property
389
+ def schema_version(self) -> str:
390
+ """Schema version string (SemVer)."""
391
+ return self._schema_version
392
+
393
+ @property
394
+ def event_id(self) -> str:
395
+ """ULID event identifier."""
396
+ return self._event_id
397
+
398
+ @property
399
+ def event_type(self) -> str:
400
+ """Namespaced event type string."""
401
+ return self._event_type
402
+
403
+ @property
404
+ def timestamp(self) -> str:
405
+ """UTC ISO-8601 timestamp string."""
406
+ return self._timestamp
407
+
408
+ @property
409
+ def source(self) -> str:
410
+ """Emitting tool in ``"name@semver"`` format."""
411
+ return self._source
412
+
413
+ @property
414
+ def payload(self) -> Mapping[str, Any]:
415
+ """Tool-specific event payload.
416
+
417
+ Returns a read-only :class:`~types.MappingProxyType` view.
418
+ For signed events (where ``checksum`` is set) the internal store is
419
+ already a ``MappingProxyType``; any attempt to mutate via ``dict``
420
+ sub-access at the top level raises :exc:`TypeError` immediately.
421
+ """
422
+ p = self._payload
423
+ if isinstance(p, MappingProxyType):
424
+ return p # already frozen — return directly, no double-wrap
425
+ return MappingProxyType(p)
426
+
427
+ @property
428
+ def trace_id(self) -> str | None:
429
+ """32-hex-char OpenTelemetry trace ID."""
430
+ return self._trace_id
431
+
432
+ @property
433
+ def span_id(self) -> str | None:
434
+ """16-hex-char OpenTelemetry span ID."""
435
+ return self._span_id
436
+
437
+ @property
438
+ def parent_span_id(self) -> str | None:
439
+ """16-hex-char parent span ID."""
440
+ return self._parent_span_id
441
+
442
+ @property
443
+ def org_id(self) -> str | None:
444
+ """Organisation identifier."""
445
+ return self._org_id
446
+
447
+ @property
448
+ def team_id(self) -> str | None:
449
+ """Team identifier."""
450
+ return self._team_id
451
+
452
+ @property
453
+ def actor_id(self) -> str | None:
454
+ """User or service-account identifier."""
455
+ return self._actor_id
456
+
457
+ @property
458
+ def session_id(self) -> str | None:
459
+ """Session identifier grouping related events."""
460
+ return self._session_id
461
+
462
+ @property
463
+ def tags(self) -> Tags | None:
464
+ """Metadata tags."""
465
+ return self._tags
466
+
467
+ @property
468
+ def checksum(self) -> str | None:
469
+ """SHA-256 payload checksum. Set by ``sign()``."""
470
+ return self._checksum
471
+
472
+ @property
473
+ def signature(self) -> str | None:
474
+ """HMAC-SHA256 chain signature. Set by ``sign()``."""
475
+ return self._signature
476
+
477
+ @property
478
+ def unknown_fields(self) -> dict[str, Any]:
479
+ """Fields present during deserialization that are not part of the known schema.
480
+
481
+ Returns a shallow copy to prevent mutation of the internal store.
482
+ """
483
+ return dict(self._unknown_fields)
484
+
485
+ @property
486
+ def prev_id(self) -> str | None:
487
+ """ULID of the preceding event in the audit chain. Set by ``sign()``."""
488
+ return self._prev_id
489
+
490
+ # ------------------------------------------------------------------
491
+ # Equality & representation
492
+ # ------------------------------------------------------------------
493
+
494
+ def __eq__(self, other: object) -> bool:
495
+ if not isinstance(other, Event):
496
+ return NotImplemented
497
+ return self._event_id == other._event_id
498
+
499
+ def __hash__(self) -> int:
500
+ """Hash by event_id (ULID) — enables set/dict membership."""
501
+ return hash(self._event_id)
502
+
503
+ def __repr__(self) -> str:
504
+ return (
505
+ f"Event(event_id={self._event_id!r}, "
506
+ f"event_type={self._event_type!r}, "
507
+ f"source={self._source!r})"
508
+ )
509
+
510
+ # ------------------------------------------------------------------
511
+ # Validation
512
+ # ------------------------------------------------------------------
513
+
514
+ def validate(self) -> None:
515
+ """Validate all envelope fields against the schema specification.
516
+
517
+ This method performs deep validation of every field. Call it
518
+ immediately after constructing an event and before signing or
519
+ exporting.
520
+
521
+ Raises:
522
+ SchemaValidationError: On the first field that fails validation.
523
+ ``exc.field`` names the failing field;
524
+ ``exc.reason`` explains the constraint.
525
+
526
+ Example::
527
+
528
+ event.validate() # raises SchemaValidationError if invalid
529
+ """
530
+ _validate_schema_version(self._schema_version)
531
+ _validate_event_id(self._event_id)
532
+ _validate_event_type(self._event_type)
533
+ _validate_timestamp(self._timestamp)
534
+ _validate_source(self._source)
535
+ _validate_payload(self._payload)
536
+
537
+ # Optional tracing fields
538
+ if self._trace_id is not None:
539
+ _validate_hex_id("trace_id", self._trace_id, 32)
540
+ if self._span_id is not None:
541
+ _validate_hex_id("span_id", self._span_id, 16)
542
+ if self._parent_span_id is not None:
543
+ _validate_hex_id("parent_span_id", self._parent_span_id, 16)
544
+
545
+ # Optional context fields
546
+ for field_name, value in [
547
+ ("org_id", self._org_id),
548
+ ("team_id", self._team_id),
549
+ ("actor_id", self._actor_id),
550
+ ("session_id", self._session_id),
551
+ ]:
552
+ if value is not None:
553
+ _validate_string_id(field_name, value)
554
+
555
+ # Optional integrity fields
556
+ if self._prev_id is not None:
557
+ _validate_ulid_field("prev_id", self._prev_id)
558
+
559
+ # ------------------------------------------------------------------
560
+ # Serialisation
561
+ # ------------------------------------------------------------------
562
+
563
+ def to_dict(self, *, omit_none: bool = True) -> dict[str, Any]:
564
+ """Return a plain :class:`dict` representation.
565
+
566
+ The dictionary uses the same field names as the JSON wire format.
567
+ Suitable for passing to logging frameworks or other serialisation
568
+ layers.
569
+
570
+ Args:
571
+ omit_none: When ``True`` (default), fields with ``None`` values are
572
+ excluded. Set to ``False`` to include explicit ``null`` values.
573
+
574
+ Returns:
575
+ An ordered dict with string keys and JSON-serialisable values.
576
+ """
577
+ raw: dict[str, Any] = {
578
+ "schema_version": self._schema_version,
579
+ "event_id": self._event_id,
580
+ "event_type": self._event_type,
581
+ "timestamp": self._timestamp,
582
+ "source": self._source,
583
+ "payload": dict(self._payload)
584
+ if isinstance(self._payload, (dict, MappingProxyType))
585
+ else self._payload,
586
+ "trace_id": self._trace_id,
587
+ "span_id": self._span_id,
588
+ "parent_span_id": self._parent_span_id,
589
+ "org_id": self._org_id,
590
+ "team_id": self._team_id,
591
+ "actor_id": self._actor_id,
592
+ "session_id": self._session_id,
593
+ "tags": self._tags.to_dict() if self._tags is not None else None,
594
+ "checksum": self._checksum,
595
+ "signature": self._signature,
596
+ "prev_id": self._prev_id,
597
+ }
598
+ # GA-05-D: round-trip unknown fields
599
+ if self._unknown_fields:
600
+ raw.update(self._unknown_fields)
601
+ if omit_none:
602
+ return {k: v for k, v in raw.items() if v is not None}
603
+ return raw
604
+
605
+ def to_json(self) -> str:
606
+ """Serialise to a canonical, deterministic JSON string.
607
+
608
+ Properties
609
+ ----------
610
+ * Keys are sorted alphabetically at every nesting level.
611
+ * ``None`` values are omitted (not serialised as ``null``).
612
+ * Uses compact separators — no whitespace.
613
+ * Guaranteed to be byte-for-byte identical for the same event on any
614
+ supported platform and Python version.
615
+
616
+ Returns:
617
+ A compact, canonical JSON string.
618
+
619
+ Raises:
620
+ SerializationError: If the payload contains a value that cannot
621
+ be serialised to JSON.
622
+
623
+ Example::
624
+
625
+ json_str = event.to_json()
626
+ assert json_str == event.to_json() # deterministic
627
+ """
628
+ try:
629
+ return json.dumps(
630
+ self.to_dict(),
631
+ sort_keys=True,
632
+ separators=(",", ":"),
633
+ default=_json_default,
634
+ ensure_ascii=False,
635
+ )
636
+ except (TypeError, ValueError, OverflowError) as exc:
637
+ raise SerializationError(
638
+ event_id=self._event_id,
639
+ reason=f"payload contains non-serialisable value: {exc}",
640
+ ) from exc
641
+
642
+ def payload_checksum(self) -> str:
643
+ """Compute SHA-256 of the canonical JSON of the payload.
644
+
645
+ Used internally by ``sign()`` (Phase 3). Safe to call at any time to
646
+ get the current payload digest.
647
+
648
+ Returns:
649
+ A hex-encoded SHA-256 digest prefixed with ``"sha256:"``.
650
+ """
651
+ canonical = json.dumps(
652
+ self._payload,
653
+ sort_keys=True,
654
+ separators=(",", ":"),
655
+ default=_json_default,
656
+ ensure_ascii=False,
657
+ )
658
+ digest = hashlib.sha256(canonical.encode("utf-8")).hexdigest()
659
+ return f"sha256:{digest}"
660
+
661
+ # ------------------------------------------------------------------
662
+ # Deserialisation
663
+ # ------------------------------------------------------------------
664
+
665
+ @classmethod
666
+ def from_dict(
667
+ cls,
668
+ data: dict[str, Any],
669
+ *,
670
+ max_size_bytes: int = 1_048_576,
671
+ max_payload_depth: int = 10,
672
+ max_tags: int = 50,
673
+ source_hint: str = "<dict>",
674
+ ) -> Event:
675
+ """Construct an :class:`Event` from a plain dictionary.
676
+
677
+ The dictionary shape matches the output of :meth:`to_dict`.
678
+
679
+ Args:
680
+ data: Dictionary with event fields.
681
+ max_size_bytes: Maximum serialised size in bytes (RFC §19.4).
682
+ Defaults to 1 MiB. Pass 0 to disable.
683
+ max_payload_depth: Maximum nesting depth of the payload object
684
+ (RFC §19.4). Defaults to 10.
685
+ max_tags: Maximum number of tag keys allowed (RFC §19.4).
686
+ Defaults to 50.
687
+ source_hint: Short label for error messages (e.g. a filename).
688
+
689
+ Returns:
690
+ A new :class:`Event` instance (not yet validated).
691
+
692
+ Raises:
693
+ DeserializationError: If a required field is missing or has an
694
+ unexpected type, or if any DoS limit is exceeded.
695
+
696
+ Example::
697
+
698
+ event = Event.from_dict(json.loads(raw_json))
699
+ event.validate()
700
+ """
701
+ _require_dict(data, source_hint)
702
+
703
+ # RFC §19.4 — DoS guards
704
+ if max_size_bytes > 0:
705
+ try:
706
+ _encoded = json.dumps(data, separators=(",", ":")).encode()
707
+ except (TypeError, ValueError):
708
+ _encoded = b""
709
+ if len(_encoded) > max_size_bytes:
710
+ raise DeserializationError(
711
+ reason=(
712
+ f"event exceeds max_size_bytes limit of {max_size_bytes} "
713
+ f"(got {len(_encoded)} bytes)"
714
+ ),
715
+ source_hint=source_hint,
716
+ )
717
+
718
+ if max_tags > 0:
719
+ tags_raw = data.get("tags")
720
+ if isinstance(tags_raw, dict) and len(tags_raw) > max_tags:
721
+ raise DeserializationError(
722
+ reason=(
723
+ f"event has {len(tags_raw)} tags, exceeding max_tags={max_tags} (RFC §19.4)"
724
+ ),
725
+ source_hint=source_hint,
726
+ )
727
+
728
+ if max_payload_depth > 0:
729
+ payload_raw = data.get("payload")
730
+ if payload_raw is not None:
731
+ _check_nesting_depth(payload_raw, max_payload_depth, source_hint)
732
+
733
+ try:
734
+ tags_raw = data.get("tags")
735
+ tags: Tags | None = Tags(**dict(tags_raw.items())) if tags_raw is not None else None
736
+
737
+ _known_keys = {
738
+ "schema_version",
739
+ "event_id",
740
+ "event_type",
741
+ "timestamp",
742
+ "source",
743
+ "payload",
744
+ "trace_id",
745
+ "span_id",
746
+ "parent_span_id",
747
+ "org_id",
748
+ "team_id",
749
+ "actor_id",
750
+ "session_id",
751
+ "tags",
752
+ "checksum",
753
+ "signature",
754
+ "prev_id",
755
+ }
756
+ _extra = {k: v for k, v in data.items() if k not in _known_keys}
757
+
758
+ evt = cls(
759
+ schema_version=_require_str(data, "schema_version", source_hint),
760
+ event_id=_require_str(data, "event_id", source_hint),
761
+ event_type=_require_str(data, "event_type", source_hint),
762
+ timestamp=_require_str(data, "timestamp", source_hint),
763
+ source=_require_str(data, "source", source_hint),
764
+ payload=_require_dict_field(data, "payload", source_hint),
765
+ trace_id=data.get("trace_id"),
766
+ span_id=data.get("span_id"),
767
+ parent_span_id=data.get("parent_span_id"),
768
+ org_id=data.get("org_id"),
769
+ team_id=data.get("team_id"),
770
+ actor_id=data.get("actor_id"),
771
+ session_id=data.get("session_id"),
772
+ tags=tags,
773
+ checksum=data.get("checksum"),
774
+ signature=data.get("signature"),
775
+ prev_id=data.get("prev_id"),
776
+ )
777
+ if _extra:
778
+ object.__setattr__(evt, "_unknown_fields", _extra)
779
+ except (KeyError, AttributeError) as exc:
780
+ raise DeserializationError(
781
+ reason=f"unexpected structure: {exc}",
782
+ source_hint=source_hint,
783
+ ) from exc
784
+ else:
785
+ return evt
786
+
787
+ # Note: from_json delegates to from_dict, which handles _unknown_fields.
788
+
789
+ @classmethod
790
+ def from_json(
791
+ cls,
792
+ json_str: str,
793
+ *,
794
+ max_size_bytes: int = 1_048_576,
795
+ max_payload_depth: int = 10,
796
+ max_tags: int = 50,
797
+ source_hint: str = "<json>",
798
+ ) -> Event:
799
+ """Construct an :class:`Event` from a JSON string.
800
+
801
+ Args:
802
+ json_str: A JSON string in the format produced by :meth:`to_json`.
803
+ max_size_bytes: Maximum string size in UTF-8 bytes (RFC §19.4).
804
+ Defaults to 1 MiB. Pass 0 to disable.
805
+ max_payload_depth: Maximum nesting depth forwarded to :meth:`from_dict`.
806
+ max_tags: Maximum number of tag keys forwarded to :meth:`from_dict`.
807
+ source_hint: Short label for error messages.
808
+
809
+ Returns:
810
+ A new :class:`Event` instance (not yet validated).
811
+
812
+ Raises:
813
+ DeserializationError: If *json_str* is not valid JSON, is missing
814
+ required fields, or exceeds any DoS limit.
815
+
816
+ Example::
817
+
818
+ event = Event.from_json(raw_json_str)
819
+ event.validate()
820
+ """
821
+ # RFC §19.4 — byte-length check before parsing to prevent parse-bomb attacks.
822
+ if max_size_bytes > 0 and len(json_str.encode()) > max_size_bytes:
823
+ raise DeserializationError(
824
+ reason=(
825
+ f"JSON string exceeds max_size_bytes limit of {max_size_bytes} "
826
+ f"(got {len(json_str.encode())} bytes)"
827
+ ),
828
+ source_hint=source_hint,
829
+ )
830
+ try:
831
+ data: dict[str, Any] = json.loads(json_str)
832
+ except json.JSONDecodeError as exc:
833
+ raise DeserializationError(
834
+ reason=f"invalid JSON: {exc}",
835
+ source_hint=source_hint,
836
+ ) from exc
837
+ return cls.from_dict(
838
+ data,
839
+ max_size_bytes=0, # already checked above
840
+ max_payload_depth=max_payload_depth,
841
+ max_tags=max_tags,
842
+ source_hint=source_hint,
843
+ )
844
+
845
+
846
+ # ---------------------------------------------------------------------------
847
+ # Validation helpers (module-private)
848
+ # ---------------------------------------------------------------------------
849
+
850
+
851
+ def _check_nesting_depth(
852
+ obj: Any,
853
+ max_depth: int,
854
+ source_hint: str,
855
+ _current: int = 0,
856
+ ) -> None:
857
+ """Recursively check that *obj* does not exceed *max_depth* nesting levels.
858
+
859
+ Raises :exc:`~spanforge.exceptions.DeserializationError` if the depth
860
+ limit is exceeded. This guards against deeply nested JSON that could
861
+ cause stack overflows or excessive CPU use (RFC §19.4).
862
+ """
863
+ if _current >= max_depth:
864
+ raise DeserializationError(
865
+ reason=(f"payload exceeds max nesting depth of {max_depth} levels (RFC §19.4)"),
866
+ source_hint=source_hint,
867
+ )
868
+ if isinstance(obj, dict):
869
+ for v in obj.values():
870
+ _check_nesting_depth(v, max_depth, source_hint, _current + 1)
871
+ elif isinstance(obj, list):
872
+ for item in obj:
873
+ _check_nesting_depth(item, max_depth, source_hint, _current + 1)
874
+
875
+
876
+ def _validate_schema_version(value: str) -> None:
877
+ if not isinstance(value, str):
878
+ raise SchemaValidationError("schema_version", value, _MUST_BE_STRING)
879
+ if value not in _ACCEPTED_SCHEMA_VERSIONS:
880
+ raise SchemaValidationError(
881
+ "schema_version",
882
+ value,
883
+ f"must be one of {sorted(_ACCEPTED_SCHEMA_VERSIONS)!r} (RFC-0001 §15.5)",
884
+ )
885
+
886
+
887
+ def _validate_event_id(value: str) -> None:
888
+ if not isinstance(value, str):
889
+ raise SchemaValidationError("event_id", value, _MUST_BE_STRING)
890
+ if not _validate_ulid(value):
891
+ raise SchemaValidationError(
892
+ "event_id",
893
+ value,
894
+ "must be a valid 26-character ULID (Crockford Base32)",
895
+ )
896
+
897
+
898
+ def _validate_event_type(value: str) -> None:
899
+ if not isinstance(value, str):
900
+ raise SchemaValidationError("event_type", value, _MUST_BE_STRING)
901
+ if not _EVENT_TYPE_RE.match(value):
902
+ raise SchemaValidationError(
903
+ "event_type",
904
+ value,
905
+ "must match 'llm.<ns>.<entity>.<action>' or 'x.<company>.<…>'",
906
+ )
907
+ if not is_registered(value):
908
+ try:
909
+ validate_custom(value)
910
+ except EventTypeError as exc:
911
+ raise SchemaValidationError(
912
+ "event_type",
913
+ value,
914
+ str(exc),
915
+ ) from exc
916
+
917
+
918
+ def _validate_timestamp(value: str) -> None:
919
+ if not isinstance(value, str):
920
+ raise SchemaValidationError("timestamp", value, _MUST_BE_STRING)
921
+ if not _TIMESTAMP_PATTERN.match(value):
922
+ raise SchemaValidationError(
923
+ "timestamp",
924
+ value,
925
+ "must be UTC ISO-8601 format: 'YYYY-MM-DDTHH:MM:SS[.ffffff]Z'",
926
+ )
927
+ # Further check that it is a real date/time
928
+ try:
929
+ _parse_timestamp(value)
930
+ except ValueError as exc:
931
+ raise SchemaValidationError("timestamp", value, f"not a valid date/time: {exc}") from exc
932
+
933
+
934
+ def _validate_source(value: str) -> None:
935
+ if not isinstance(value, str):
936
+ raise SchemaValidationError("source", value, _MUST_BE_STRING)
937
+ if not _SOURCE_PATTERN.match(value):
938
+ raise SchemaValidationError(
939
+ "source",
940
+ value,
941
+ "must match 'tool-name@semver', e.g. 'llm-trace@0.3.1'",
942
+ )
943
+
944
+
945
+ def _validate_payload(value: object) -> None:
946
+ if not isinstance(value, (dict, MappingProxyType)):
947
+ raise SchemaValidationError("payload", value, "must be a non-empty dict")
948
+ if not value:
949
+ raise SchemaValidationError(
950
+ "payload", value, "must be a non-empty dict (empty dict is not allowed)"
951
+ )
952
+
953
+
954
+ def _validate_hex_id(field: str, value: str, expected_len: int) -> None:
955
+ if not isinstance(value, str):
956
+ raise SchemaValidationError(field, value, _MUST_BE_STRING)
957
+ pattern = _TRACE_ID_PATTERN if expected_len == 32 else _SPAN_ID_PATTERN
958
+ if not pattern.match(value):
959
+ raise SchemaValidationError(
960
+ field,
961
+ value,
962
+ f"must be exactly {expected_len} lowercase hex characters",
963
+ )
964
+
965
+
966
+ def _validate_string_id(field: str, value: str) -> None:
967
+ if not isinstance(value, str):
968
+ raise SchemaValidationError(field, value, _MUST_BE_STRING)
969
+ if not value:
970
+ raise SchemaValidationError(field, value, "must be a non-empty string")
971
+
972
+
973
+ def _validate_ulid_field(field: str, value: str) -> None:
974
+ if not isinstance(value, str):
975
+ raise SchemaValidationError(field, value, _MUST_BE_STRING)
976
+ if not _validate_ulid(value):
977
+ raise SchemaValidationError(field, value, "must be a valid 26-character ULID")
978
+
979
+
980
+ # ---------------------------------------------------------------------------
981
+ # Deserialisation helpers (module-private)
982
+ # ---------------------------------------------------------------------------
983
+
984
+
985
+ def _require_dict(data: object, source_hint: str) -> None:
986
+ if not isinstance(data, dict):
987
+ raise DeserializationError(
988
+ reason=f"expected a JSON object at top level, got {type(data).__name__}",
989
+ source_hint=source_hint,
990
+ )
991
+
992
+
993
+ def _require_str(data: dict[str, Any], key: str, source_hint: str) -> str:
994
+ value = data.get(key)
995
+ if value is None:
996
+ raise DeserializationError(
997
+ reason=f"required field '{key}' is missing",
998
+ source_hint=source_hint,
999
+ )
1000
+ if not isinstance(value, str):
1001
+ raise DeserializationError(
1002
+ reason=f"field '{key}' must be a string, got {type(value).__name__}",
1003
+ source_hint=source_hint,
1004
+ )
1005
+ return value
1006
+
1007
+
1008
+ def _require_dict_field(data: dict[str, Any], key: str, source_hint: str) -> dict[str, Any]:
1009
+ value = data.get(key)
1010
+ if value is None:
1011
+ raise DeserializationError(
1012
+ reason=f"required field '{key}' is missing",
1013
+ source_hint=source_hint,
1014
+ )
1015
+ if not isinstance(value, dict):
1016
+ raise DeserializationError(
1017
+ reason=f"field '{key}' must be an object, got {type(value).__name__}",
1018
+ source_hint=source_hint,
1019
+ )
1020
+ return value
1021
+
1022
+
1023
+ # ---------------------------------------------------------------------------
1024
+ # Serialisation helpers (module-private)
1025
+ # ---------------------------------------------------------------------------
1026
+
1027
+
1028
+ def _json_default(obj: object) -> object:
1029
+ """JSON serialiser fallback for non-standard types."""
1030
+ if isinstance(obj, datetime.datetime):
1031
+ return _datetime_to_iso(obj)
1032
+ if isinstance(obj, EventType):
1033
+ return obj.value
1034
+ raise TypeError(f"Object of type {type(obj).__name__!r} is not JSON serialisable")
1035
+
1036
+
1037
+ def _utcnow_iso() -> str:
1038
+ """Return the current UTC time as an ISO-8601 string."""
1039
+ now = datetime.datetime.now(tz=datetime.timezone.utc)
1040
+ return _datetime_to_iso(now)
1041
+
1042
+
1043
+ def _datetime_to_iso(dt: datetime.datetime) -> str:
1044
+ """Format a :class:`datetime.datetime` as ``'YYYY-MM-DDTHH:MM:SS.ffffffZ'``."""
1045
+ if dt.tzinfo is None:
1046
+ # Assume UTC if naive
1047
+ dt = dt.replace(tzinfo=datetime.timezone.utc)
1048
+ # Normalise to UTC
1049
+ dt_utc = dt.astimezone(datetime.timezone.utc)
1050
+ return dt_utc.strftime("%Y-%m-%dT%H:%M:%S.%f") + "Z"
1051
+
1052
+
1053
+ def _parse_timestamp(value: str) -> datetime.datetime:
1054
+ """Parse an ISO-8601 UTC timestamp string."""
1055
+ # Python < 3.11 does not support fromisoformat with trailing 'Z'
1056
+ if value.endswith("Z"):
1057
+ value = value[:-1] + "+00:00"
1058
+ if sys.version_info >= (3, 11):
1059
+ return datetime.datetime.fromisoformat(value)
1060
+ # Fallback for Python 3.9 / 3.10 # pragma: no cover
1061
+ try: # pragma: no cover
1062
+ return datetime.datetime.strptime(value, "%Y-%m-%dT%H:%M:%S.%f+00:00") # pragma: no cover
1063
+ except ValueError: # pragma: no cover
1064
+ return datetime.datetime.strptime(value, "%Y-%m-%dT%H:%M:%S+00:00") # pragma: no cover