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.
- spanforge/__init__.py +695 -0
- spanforge/_batch_exporter.py +322 -0
- spanforge/_cli.py +3081 -0
- spanforge/_hooks.py +340 -0
- spanforge/_server.py +953 -0
- spanforge/_span.py +1015 -0
- spanforge/_store.py +287 -0
- spanforge/_stream.py +654 -0
- spanforge/_trace.py +334 -0
- spanforge/_tracer.py +253 -0
- spanforge/actor.py +141 -0
- spanforge/alerts.py +464 -0
- spanforge/auto.py +181 -0
- spanforge/baseline.py +336 -0
- spanforge/config.py +460 -0
- spanforge/consent.py +227 -0
- spanforge/consumer.py +379 -0
- spanforge/core/__init__.py +5 -0
- spanforge/core/compliance_mapping.py +1060 -0
- spanforge/cost.py +597 -0
- spanforge/debug.py +514 -0
- spanforge/drift.py +488 -0
- spanforge/egress.py +63 -0
- spanforge/eval.py +575 -0
- spanforge/event.py +1052 -0
- spanforge/exceptions.py +246 -0
- spanforge/explain.py +181 -0
- spanforge/export/__init__.py +50 -0
- spanforge/export/append_only.py +342 -0
- spanforge/export/cloud.py +349 -0
- spanforge/export/datadog.py +495 -0
- spanforge/export/grafana.py +331 -0
- spanforge/export/jsonl.py +198 -0
- spanforge/export/otel_bridge.py +291 -0
- spanforge/export/otlp.py +817 -0
- spanforge/export/otlp_bridge.py +231 -0
- spanforge/export/redis_backend.py +282 -0
- spanforge/export/webhook.py +302 -0
- spanforge/exporters/__init__.py +29 -0
- spanforge/exporters/console.py +271 -0
- spanforge/exporters/jsonl.py +144 -0
- spanforge/hitl.py +297 -0
- spanforge/inspect.py +429 -0
- spanforge/integrations/__init__.py +39 -0
- spanforge/integrations/_pricing.py +277 -0
- spanforge/integrations/anthropic.py +388 -0
- spanforge/integrations/bedrock.py +306 -0
- spanforge/integrations/crewai.py +251 -0
- spanforge/integrations/gemini.py +349 -0
- spanforge/integrations/groq.py +444 -0
- spanforge/integrations/langchain.py +349 -0
- spanforge/integrations/llamaindex.py +370 -0
- spanforge/integrations/ollama.py +286 -0
- spanforge/integrations/openai.py +370 -0
- spanforge/integrations/together.py +485 -0
- spanforge/metrics.py +393 -0
- spanforge/metrics_export.py +342 -0
- spanforge/migrate.py +278 -0
- spanforge/model_registry.py +282 -0
- spanforge/models.py +407 -0
- spanforge/namespaces/__init__.py +215 -0
- spanforge/namespaces/audit.py +253 -0
- spanforge/namespaces/cache.py +209 -0
- spanforge/namespaces/chain.py +74 -0
- spanforge/namespaces/confidence.py +69 -0
- spanforge/namespaces/consent.py +85 -0
- spanforge/namespaces/cost.py +175 -0
- spanforge/namespaces/decision.py +135 -0
- spanforge/namespaces/diff.py +146 -0
- spanforge/namespaces/drift.py +79 -0
- spanforge/namespaces/eval_.py +232 -0
- spanforge/namespaces/fence.py +180 -0
- spanforge/namespaces/guard.py +104 -0
- spanforge/namespaces/hitl.py +92 -0
- spanforge/namespaces/latency.py +69 -0
- spanforge/namespaces/prompt.py +185 -0
- spanforge/namespaces/redact.py +172 -0
- spanforge/namespaces/template.py +197 -0
- spanforge/namespaces/tool_call.py +76 -0
- spanforge/namespaces/trace.py +1006 -0
- spanforge/normalizer.py +183 -0
- spanforge/presidio_backend.py +149 -0
- spanforge/processor.py +258 -0
- spanforge/prompt_registry.py +415 -0
- spanforge/py.typed +0 -0
- spanforge/redact.py +780 -0
- spanforge/sampling.py +500 -0
- spanforge/schemas/v1.0/schema.json +170 -0
- spanforge/schemas/v2.0/schema.json +536 -0
- spanforge/signing.py +1152 -0
- spanforge/stream.py +559 -0
- spanforge/testing.py +376 -0
- spanforge/trace.py +199 -0
- spanforge/types.py +696 -0
- spanforge/ulid.py +304 -0
- spanforge/validate.py +383 -0
- spanforge-2.0.0.dist-info/METADATA +1777 -0
- spanforge-2.0.0.dist-info/RECORD +101 -0
- spanforge-2.0.0.dist-info/WHEEL +4 -0
- spanforge-2.0.0.dist-info/entry_points.txt +5 -0
- spanforge-2.0.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,215 @@
|
|
|
1
|
+
"""spanforge.namespaces — Namespace-specific payload dataclasses (v2.0).
|
|
2
|
+
|
|
3
|
+
Each sub-module provides dataclasses that model the ``payload`` field of
|
|
4
|
+
:class:`~spanforge.event.Event` for a given namespace.
|
|
5
|
+
|
|
6
|
+
All payload classes share the same contract:
|
|
7
|
+
|
|
8
|
+
* ``to_dict() -> dict`` — serialise to a plain dict for ``Event.payload``.
|
|
9
|
+
* ``from_dict(data) -> cls`` — reconstruct from a plain dict.
|
|
10
|
+
* ``__post_init__`` — validates every field at construction time.
|
|
11
|
+
|
|
12
|
+
Sub-modules
|
|
13
|
+
-----------
|
|
14
|
+
audit
|
|
15
|
+
:class:`AuditKeyRotatedPayload`, :class:`AuditChainVerifiedPayload`,
|
|
16
|
+
:class:`AuditChainTamperedPayload`, :class:`AuditChainPayload`
|
|
17
|
+
cache
|
|
18
|
+
:class:`CacheHitPayload`, :class:`CacheMissPayload`,
|
|
19
|
+
:class:`CacheEvictedPayload`, :class:`CacheWrittenPayload`
|
|
20
|
+
chain (RFC-0001 SPANFORGE)
|
|
21
|
+
:class:`ChainPayload`
|
|
22
|
+
confidence (RFC-0001 SPANFORGE)
|
|
23
|
+
:class:`ConfidencePayload`
|
|
24
|
+
consent (RFC-0001 SPANFORGE)
|
|
25
|
+
:class:`ConsentPayload`
|
|
26
|
+
hitl (RFC-0001 SPANFORGE)
|
|
27
|
+
:class:`HITLPayload`
|
|
28
|
+
playbook (RFC-0001 SPANFORGE)
|
|
29
|
+
*removed*
|
|
30
|
+
cost
|
|
31
|
+
:class:`CostTokenRecordedPayload`, :class:`CostSessionRecordedPayload`,
|
|
32
|
+
:class:`CostAttributedPayload`
|
|
33
|
+
decision (RFC-0001 SPANFORGE)
|
|
34
|
+
:class:`DecisionDriver`, :class:`DecisionPayload`
|
|
35
|
+
diff
|
|
36
|
+
:class:`DiffComputedPayload`, :class:`DiffRegressionFlaggedPayload`
|
|
37
|
+
drift (RFC-0001 SPANFORGE)
|
|
38
|
+
:class:`DriftPayload`
|
|
39
|
+
eval_
|
|
40
|
+
:class:`EvalScoreRecordedPayload`, :class:`EvalRegressionDetectedPayload`,
|
|
41
|
+
:class:`EvalScenarioStartedPayload`, :class:`EvalScenarioCompletedPayload`
|
|
42
|
+
fence
|
|
43
|
+
:class:`FenceValidatedPayload`, :class:`FenceRetryTriggeredPayload`,
|
|
44
|
+
:class:`FenceMaxRetriesExceededPayload`
|
|
45
|
+
guard
|
|
46
|
+
:class:`GuardPayload`
|
|
47
|
+
latency (RFC-0001 SPANFORGE)
|
|
48
|
+
:class:`LatencyPayload`
|
|
49
|
+
playbook (RFC-0001 SPANFORGE)
|
|
50
|
+
*removed*
|
|
51
|
+
prompt
|
|
52
|
+
:class:`PromptRenderedPayload`, :class:`PromptTemplateLoadedPayload`,
|
|
53
|
+
:class:`PromptVersionChangedPayload`
|
|
54
|
+
redact
|
|
55
|
+
:class:`RedactPiiDetectedPayload`, :class:`RedactPhiDetectedPayload`,
|
|
56
|
+
:class:`RedactAppliedPayload`
|
|
57
|
+
template
|
|
58
|
+
:class:`TemplateRegisteredPayload`, :class:`TemplateVariableBoundPayload`,
|
|
59
|
+
:class:`TemplateValidationFailedPayload`
|
|
60
|
+
tool_call (RFC-0001 SPANFORGE)
|
|
61
|
+
:class:`ToolCallPayload`
|
|
62
|
+
trace
|
|
63
|
+
:class:`GenAISystem`, :class:`GenAIOperationName`, :class:`SpanKind`,
|
|
64
|
+
:class:`TokenUsage`, :class:`ModelInfo`, :class:`CostBreakdown`,
|
|
65
|
+
:class:`PricingTier`, :class:`ToolCall`, :class:`ReasoningStep`,
|
|
66
|
+
:class:`DecisionPoint`, :class:`SpanPayload`, :class:`AgentStepPayload`,
|
|
67
|
+
:class:`AgentRunPayload`
|
|
68
|
+
"""
|
|
69
|
+
|
|
70
|
+
from spanforge.namespaces.audit import (
|
|
71
|
+
AuditChainPayload,
|
|
72
|
+
AuditChainTamperedPayload,
|
|
73
|
+
AuditChainVerifiedPayload,
|
|
74
|
+
AuditKeyRotatedPayload,
|
|
75
|
+
)
|
|
76
|
+
from spanforge.namespaces.cache import (
|
|
77
|
+
CacheEvictedPayload,
|
|
78
|
+
CacheHitPayload,
|
|
79
|
+
CacheMissPayload,
|
|
80
|
+
CacheWrittenPayload,
|
|
81
|
+
)
|
|
82
|
+
from spanforge.namespaces.chain import ChainPayload
|
|
83
|
+
from spanforge.namespaces.confidence import ConfidencePayload
|
|
84
|
+
from spanforge.namespaces.consent import ConsentPayload
|
|
85
|
+
from spanforge.namespaces.hitl import HITLPayload
|
|
86
|
+
from spanforge.namespaces.cost import (
|
|
87
|
+
CostAttributedPayload,
|
|
88
|
+
CostSessionRecordedPayload,
|
|
89
|
+
CostTokenRecordedPayload,
|
|
90
|
+
)
|
|
91
|
+
from spanforge.namespaces.decision import DecisionDriver, DecisionPayload
|
|
92
|
+
from spanforge.namespaces.diff import (
|
|
93
|
+
DiffComputedPayload,
|
|
94
|
+
DiffRegressionFlaggedPayload,
|
|
95
|
+
)
|
|
96
|
+
from spanforge.namespaces.drift import DriftPayload
|
|
97
|
+
from spanforge.namespaces.eval_ import (
|
|
98
|
+
EvalRegressionDetectedPayload,
|
|
99
|
+
EvalScenarioCompletedPayload,
|
|
100
|
+
EvalScenarioStartedPayload,
|
|
101
|
+
EvalScoreRecordedPayload,
|
|
102
|
+
)
|
|
103
|
+
from spanforge.namespaces.fence import (
|
|
104
|
+
FenceMaxRetriesExceededPayload,
|
|
105
|
+
FenceRetryTriggeredPayload,
|
|
106
|
+
FenceValidatedPayload,
|
|
107
|
+
)
|
|
108
|
+
from spanforge.namespaces.guard import GuardPayload
|
|
109
|
+
from spanforge.namespaces.latency import LatencyPayload
|
|
110
|
+
from spanforge.namespaces.prompt import (
|
|
111
|
+
PromptRenderedPayload,
|
|
112
|
+
PromptTemplateLoadedPayload,
|
|
113
|
+
PromptVersionChangedPayload,
|
|
114
|
+
)
|
|
115
|
+
from spanforge.namespaces.redact import (
|
|
116
|
+
RedactAppliedPayload,
|
|
117
|
+
RedactPhiDetectedPayload,
|
|
118
|
+
RedactPiiDetectedPayload,
|
|
119
|
+
)
|
|
120
|
+
from spanforge.namespaces.template import (
|
|
121
|
+
TemplateRegisteredPayload,
|
|
122
|
+
TemplateValidationFailedPayload,
|
|
123
|
+
TemplateVariableBoundPayload,
|
|
124
|
+
)
|
|
125
|
+
from spanforge.namespaces.tool_call import ToolCallPayload
|
|
126
|
+
from spanforge.namespaces.trace import (
|
|
127
|
+
AgentRunPayload,
|
|
128
|
+
AgentStepPayload,
|
|
129
|
+
CostBreakdown,
|
|
130
|
+
DecisionPoint,
|
|
131
|
+
GenAIOperationName,
|
|
132
|
+
GenAISystem,
|
|
133
|
+
ModelInfo,
|
|
134
|
+
PricingTier,
|
|
135
|
+
ReasoningStep,
|
|
136
|
+
SpanKind,
|
|
137
|
+
SpanPayload,
|
|
138
|
+
TokenUsage,
|
|
139
|
+
ToolCall,
|
|
140
|
+
)
|
|
141
|
+
|
|
142
|
+
__all__: list = [
|
|
143
|
+
"AgentRunPayload",
|
|
144
|
+
"AgentStepPayload",
|
|
145
|
+
# audit (legacy + RFC-0001 SPANFORGE)
|
|
146
|
+
"AuditChainPayload",
|
|
147
|
+
"AuditChainTamperedPayload",
|
|
148
|
+
"AuditChainVerifiedPayload",
|
|
149
|
+
"AuditKeyRotatedPayload",
|
|
150
|
+
# cache
|
|
151
|
+
"CacheEvictedPayload",
|
|
152
|
+
"CacheHitPayload",
|
|
153
|
+
"CacheMissPayload",
|
|
154
|
+
"CacheWrittenPayload",
|
|
155
|
+
# chain (RFC-0001 SPANFORGE)
|
|
156
|
+
"ChainPayload",
|
|
157
|
+
# confidence (RFC-0001 SPANFORGE)
|
|
158
|
+
"ConfidencePayload",
|
|
159
|
+
# consent (RFC-0001 SPANFORGE)
|
|
160
|
+
"ConsentPayload",
|
|
161
|
+
# cost
|
|
162
|
+
"CostAttributedPayload",
|
|
163
|
+
"CostBreakdown",
|
|
164
|
+
"CostSessionRecordedPayload",
|
|
165
|
+
"CostTokenRecordedPayload",
|
|
166
|
+
# decision (RFC-0001 SPANFORGE)
|
|
167
|
+
"DecisionDriver",
|
|
168
|
+
"DecisionPayload",
|
|
169
|
+
# diff
|
|
170
|
+
"DiffComputedPayload",
|
|
171
|
+
"DiffRegressionFlaggedPayload",
|
|
172
|
+
# drift (RFC-0001 SPANFORGE)
|
|
173
|
+
"DriftPayload",
|
|
174
|
+
# eval
|
|
175
|
+
"EvalRegressionDetectedPayload",
|
|
176
|
+
"EvalScenarioCompletedPayload",
|
|
177
|
+
"EvalScenarioStartedPayload",
|
|
178
|
+
"EvalScoreRecordedPayload",
|
|
179
|
+
# fence
|
|
180
|
+
"FenceMaxRetriesExceededPayload",
|
|
181
|
+
"FenceRetryTriggeredPayload",
|
|
182
|
+
"FenceValidatedPayload",
|
|
183
|
+
# guard
|
|
184
|
+
"GuardPayload",
|
|
185
|
+
# hitl (RFC-0001 SPANFORGE)
|
|
186
|
+
"HITLPayload",
|
|
187
|
+
# latency (RFC-0001 SPANFORGE)
|
|
188
|
+
"LatencyPayload",
|
|
189
|
+
# trace — value objects and payloads
|
|
190
|
+
"GenAIOperationName",
|
|
191
|
+
"GenAISystem",
|
|
192
|
+
"ModelInfo",
|
|
193
|
+
"PricingTier",
|
|
194
|
+
# prompt
|
|
195
|
+
"PromptRenderedPayload",
|
|
196
|
+
"PromptTemplateLoadedPayload",
|
|
197
|
+
"PromptVersionChangedPayload",
|
|
198
|
+
"ReasoningStep",
|
|
199
|
+
# redact
|
|
200
|
+
"RedactAppliedPayload",
|
|
201
|
+
"RedactPhiDetectedPayload",
|
|
202
|
+
"RedactPiiDetectedPayload",
|
|
203
|
+
"SpanKind",
|
|
204
|
+
"SpanPayload",
|
|
205
|
+
# template
|
|
206
|
+
"TemplateRegisteredPayload",
|
|
207
|
+
"TemplateValidationFailedPayload",
|
|
208
|
+
"TemplateVariableBoundPayload",
|
|
209
|
+
"TokenUsage",
|
|
210
|
+
"ToolCall",
|
|
211
|
+
# tool_call (RFC-0001 SPANFORGE)
|
|
212
|
+
"ToolCallPayload",
|
|
213
|
+
# Backward-compat trace value object
|
|
214
|
+
"DecisionPoint",
|
|
215
|
+
]
|
|
@@ -0,0 +1,253 @@
|
|
|
1
|
+
"""spanforge.namespaces.audit — Audit chain payload types (RFC-0001 §11 + RFC-0001 SPANFORGE).
|
|
2
|
+
|
|
3
|
+
Classes
|
|
4
|
+
-------
|
|
5
|
+
AuditKeyRotatedPayload llm.audit.key.rotated
|
|
6
|
+
AuditChainVerifiedPayload llm.audit.chain.verified
|
|
7
|
+
AuditChainTamperedPayload llm.audit.chain.tampered
|
|
8
|
+
AuditChainPayload audit.event_signed / audit.chain_verified / audit.tamper_detected
|
|
9
|
+
RFC-0001 SPANFORGE tamper-evident cross-reference chain
|
|
10
|
+
"""
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
from dataclasses import dataclass, field
|
|
14
|
+
from typing import Any
|
|
15
|
+
|
|
16
|
+
__all__ = [
|
|
17
|
+
"AuditChainPayload",
|
|
18
|
+
"AuditChainTamperedPayload",
|
|
19
|
+
"AuditChainVerifiedPayload",
|
|
20
|
+
"AuditKeyRotatedPayload",
|
|
21
|
+
]
|
|
22
|
+
|
|
23
|
+
_VALID_ROTATION_REASONS = frozenset({
|
|
24
|
+
"scheduled", "suspected_compromise", "policy_update", "key_expiry", "manual"
|
|
25
|
+
})
|
|
26
|
+
_VALID_SEVERITIES = frozenset({"low", "medium", "high", "critical"})
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
@dataclass
|
|
30
|
+
class AuditKeyRotatedPayload:
|
|
31
|
+
"""RFC-0001 §11.5 — An HMAC signing key was rotated.
|
|
32
|
+
|
|
33
|
+
``key_algorithm`` defaults to ``"HMAC-SHA256"`` (the only algorithm
|
|
34
|
+
mandated by the RFC). ``effective_from_event_id`` is the ULID of the
|
|
35
|
+
first event signed with the new key.
|
|
36
|
+
"""
|
|
37
|
+
|
|
38
|
+
key_id: str
|
|
39
|
+
previous_key_id: str
|
|
40
|
+
rotated_at: str # ISO 8601 timestamp with exactly 6 decimal places
|
|
41
|
+
rotated_by: str
|
|
42
|
+
rotation_reason: str | None = None
|
|
43
|
+
key_algorithm: str = "HMAC-SHA256"
|
|
44
|
+
effective_from_event_id: str | None = None # ULID
|
|
45
|
+
|
|
46
|
+
def __post_init__(self) -> None:
|
|
47
|
+
if not self.key_id:
|
|
48
|
+
raise ValueError("AuditKeyRotatedPayload.key_id must be non-empty")
|
|
49
|
+
if not self.previous_key_id:
|
|
50
|
+
raise ValueError("AuditKeyRotatedPayload.previous_key_id must be non-empty")
|
|
51
|
+
if not self.rotated_at:
|
|
52
|
+
raise ValueError("AuditKeyRotatedPayload.rotated_at must be non-empty")
|
|
53
|
+
if not self.rotated_by:
|
|
54
|
+
raise ValueError("AuditKeyRotatedPayload.rotated_by must be non-empty")
|
|
55
|
+
if self.rotation_reason is not None and self.rotation_reason not in _VALID_ROTATION_REASONS:
|
|
56
|
+
raise ValueError(
|
|
57
|
+
f"AuditKeyRotatedPayload.rotation_reason must be one of {sorted(_VALID_ROTATION_REASONS)}" # noqa: E501
|
|
58
|
+
)
|
|
59
|
+
|
|
60
|
+
def to_dict(self) -> dict[str, Any]:
|
|
61
|
+
"""Serialise the payload to a plain ``dict``."""
|
|
62
|
+
d: dict[str, Any] = {
|
|
63
|
+
"key_id": self.key_id,
|
|
64
|
+
"previous_key_id": self.previous_key_id,
|
|
65
|
+
"rotated_at": self.rotated_at,
|
|
66
|
+
"rotated_by": self.rotated_by,
|
|
67
|
+
"key_algorithm": self.key_algorithm,
|
|
68
|
+
}
|
|
69
|
+
if self.rotation_reason is not None:
|
|
70
|
+
d["rotation_reason"] = self.rotation_reason
|
|
71
|
+
if self.effective_from_event_id is not None:
|
|
72
|
+
d["effective_from_event_id"] = self.effective_from_event_id
|
|
73
|
+
return d
|
|
74
|
+
|
|
75
|
+
@classmethod
|
|
76
|
+
def from_dict(cls, data: dict[str, Any]) -> AuditKeyRotatedPayload:
|
|
77
|
+
"""Deserialise from a plain ``dict``."""
|
|
78
|
+
return cls(
|
|
79
|
+
key_id=data["key_id"],
|
|
80
|
+
previous_key_id=data["previous_key_id"],
|
|
81
|
+
rotated_at=data["rotated_at"],
|
|
82
|
+
rotated_by=data["rotated_by"],
|
|
83
|
+
rotation_reason=data.get("rotation_reason"),
|
|
84
|
+
key_algorithm=data.get("key_algorithm", "HMAC-SHA256"),
|
|
85
|
+
effective_from_event_id=data.get("effective_from_event_id"),
|
|
86
|
+
)
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
@dataclass
|
|
90
|
+
class AuditChainVerifiedPayload:
|
|
91
|
+
"""RFC-0001 §11 — An audit chain segment was verified intact."""
|
|
92
|
+
|
|
93
|
+
verified_from_event_id: str
|
|
94
|
+
verified_to_event_id: str
|
|
95
|
+
event_count: int
|
|
96
|
+
verified_at: str
|
|
97
|
+
verified_by: str
|
|
98
|
+
|
|
99
|
+
def __post_init__(self) -> None:
|
|
100
|
+
if not self.verified_from_event_id:
|
|
101
|
+
raise ValueError("AuditChainVerifiedPayload.verified_from_event_id must be non-empty")
|
|
102
|
+
if not self.verified_to_event_id:
|
|
103
|
+
raise ValueError("AuditChainVerifiedPayload.verified_to_event_id must be non-empty")
|
|
104
|
+
if not isinstance(self.event_count, int) or self.event_count < 0:
|
|
105
|
+
raise ValueError("AuditChainVerifiedPayload.event_count must be a non-negative int")
|
|
106
|
+
if not self.verified_at:
|
|
107
|
+
raise ValueError("AuditChainVerifiedPayload.verified_at must be non-empty")
|
|
108
|
+
if not self.verified_by:
|
|
109
|
+
raise ValueError("AuditChainVerifiedPayload.verified_by must be non-empty")
|
|
110
|
+
|
|
111
|
+
def to_dict(self) -> dict[str, Any]:
|
|
112
|
+
"""Serialise the payload to a plain ``dict``."""
|
|
113
|
+
return {
|
|
114
|
+
"verified_from_event_id": self.verified_from_event_id,
|
|
115
|
+
"verified_to_event_id": self.verified_to_event_id,
|
|
116
|
+
"event_count": self.event_count,
|
|
117
|
+
"verified_at": self.verified_at,
|
|
118
|
+
"verified_by": self.verified_by,
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
@classmethod
|
|
122
|
+
def from_dict(cls, data: dict[str, Any]) -> AuditChainVerifiedPayload:
|
|
123
|
+
"""Deserialise from a plain ``dict``."""
|
|
124
|
+
return cls(
|
|
125
|
+
verified_from_event_id=data["verified_from_event_id"],
|
|
126
|
+
verified_to_event_id=data["verified_to_event_id"],
|
|
127
|
+
event_count=int(data["event_count"]),
|
|
128
|
+
verified_at=data["verified_at"],
|
|
129
|
+
verified_by=data["verified_by"],
|
|
130
|
+
)
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
@dataclass
|
|
134
|
+
class AuditChainTamperedPayload:
|
|
135
|
+
"""RFC-0001 §11 — Tampering or a gap was detected in the audit chain."""
|
|
136
|
+
|
|
137
|
+
first_tampered_event_id: str
|
|
138
|
+
tampered_count: int
|
|
139
|
+
detected_at: str
|
|
140
|
+
detected_by: str
|
|
141
|
+
gap_count: int | None = None
|
|
142
|
+
gap_prev_ids: list[str] = field(default_factory=list)
|
|
143
|
+
severity: str | None = None # "low"|"medium"|"high"|"critical"
|
|
144
|
+
|
|
145
|
+
def __post_init__(self) -> None:
|
|
146
|
+
if not self.first_tampered_event_id:
|
|
147
|
+
raise ValueError("AuditChainTamperedPayload.first_tampered_event_id must be non-empty")
|
|
148
|
+
if not isinstance(self.tampered_count, int) or self.tampered_count < 0:
|
|
149
|
+
raise ValueError("AuditChainTamperedPayload.tampered_count must be a non-negative int")
|
|
150
|
+
if not self.detected_at:
|
|
151
|
+
raise ValueError("AuditChainTamperedPayload.detected_at must be non-empty")
|
|
152
|
+
if not self.detected_by:
|
|
153
|
+
raise ValueError("AuditChainTamperedPayload.detected_by must be non-empty")
|
|
154
|
+
if self.severity is not None and self.severity not in _VALID_SEVERITIES:
|
|
155
|
+
raise ValueError(f"AuditChainTamperedPayload.severity must be one of {sorted(_VALID_SEVERITIES)}") # noqa: E501
|
|
156
|
+
|
|
157
|
+
def to_dict(self) -> dict[str, Any]:
|
|
158
|
+
"""Serialise the payload to a plain ``dict``."""
|
|
159
|
+
d: dict[str, Any] = {
|
|
160
|
+
"first_tampered_event_id": self.first_tampered_event_id,
|
|
161
|
+
"tampered_count": self.tampered_count,
|
|
162
|
+
"detected_at": self.detected_at,
|
|
163
|
+
"detected_by": self.detected_by,
|
|
164
|
+
}
|
|
165
|
+
if self.gap_count is not None:
|
|
166
|
+
d["gap_count"] = self.gap_count
|
|
167
|
+
if self.gap_prev_ids:
|
|
168
|
+
d["gap_prev_ids"] = list(self.gap_prev_ids)
|
|
169
|
+
if self.severity is not None:
|
|
170
|
+
d["severity"] = self.severity
|
|
171
|
+
return d
|
|
172
|
+
|
|
173
|
+
@classmethod
|
|
174
|
+
def from_dict(cls, data: dict[str, Any]) -> AuditChainTamperedPayload:
|
|
175
|
+
"""Deserialise from a plain ``dict``."""
|
|
176
|
+
return cls(
|
|
177
|
+
first_tampered_event_id=data["first_tampered_event_id"],
|
|
178
|
+
tampered_count=int(data["tampered_count"]),
|
|
179
|
+
detected_at=data["detected_at"],
|
|
180
|
+
detected_by=data["detected_by"],
|
|
181
|
+
gap_count=int(data["gap_count"]) if "gap_count" in data else None,
|
|
182
|
+
gap_prev_ids=list(data.get("gap_prev_ids", [])),
|
|
183
|
+
severity=data.get("severity"),
|
|
184
|
+
)
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
@dataclass
|
|
188
|
+
class AuditChainPayload:
|
|
189
|
+
"""RFC-0001 SPANFORGE — tamper-evident cross-reference audit chain event.
|
|
190
|
+
|
|
191
|
+
Every event emitted in the 10 RFC-0001 SPANFORGE namespaces is cross-
|
|
192
|
+
referenced into this immutable audit chain. Each entry holds the HMAC
|
|
193
|
+
of the referenced event and the chained HMAC of all prior chain entries,
|
|
194
|
+
making any post-emission mutation detectable.
|
|
195
|
+
|
|
196
|
+
Events:
|
|
197
|
+
audit.event_signed — a new event was appended to the chain
|
|
198
|
+
audit.chain_verified — a chain segment was verified intact
|
|
199
|
+
audit.tamper_detected — a break in the HMAC sequence was detected
|
|
200
|
+
"""
|
|
201
|
+
|
|
202
|
+
event_id: str # ULID of the referenced event
|
|
203
|
+
event_type: str # wire event type string of the referenced event
|
|
204
|
+
event_hmac: str # HMAC-SHA256 of the referenced event canonical JSON
|
|
205
|
+
chain_position: int # monotonically increasing position in the chain
|
|
206
|
+
signer_id: str # identity of the signing service / key ID
|
|
207
|
+
signed_at: str # ISO 8601 timestamp with 6 decimal places
|
|
208
|
+
prev_chain_hmac: str | None = None # HMAC of the previous chain entry; None for entry 0
|
|
209
|
+
|
|
210
|
+
def __post_init__(self) -> None:
|
|
211
|
+
if not self.event_id:
|
|
212
|
+
raise ValueError("AuditChainPayload.event_id must be non-empty")
|
|
213
|
+
if not self.event_type:
|
|
214
|
+
raise ValueError("AuditChainPayload.event_type must be non-empty")
|
|
215
|
+
if not self.event_hmac:
|
|
216
|
+
raise ValueError("AuditChainPayload.event_hmac must be non-empty")
|
|
217
|
+
if not isinstance(self.chain_position, int) or self.chain_position < 0:
|
|
218
|
+
raise ValueError("AuditChainPayload.chain_position must be a non-negative int")
|
|
219
|
+
if not self.signer_id:
|
|
220
|
+
raise ValueError("AuditChainPayload.signer_id must be non-empty")
|
|
221
|
+
if not self.signed_at:
|
|
222
|
+
raise ValueError("AuditChainPayload.signed_at must be non-empty")
|
|
223
|
+
if self.chain_position > 0 and not self.prev_chain_hmac:
|
|
224
|
+
raise ValueError(
|
|
225
|
+
"AuditChainPayload.prev_chain_hmac is required when chain_position > 0"
|
|
226
|
+
)
|
|
227
|
+
|
|
228
|
+
def to_dict(self) -> dict[str, Any]:
|
|
229
|
+
"""Serialise the payload to a plain ``dict``."""
|
|
230
|
+
d: dict[str, Any] = {
|
|
231
|
+
"event_id": self.event_id,
|
|
232
|
+
"event_type": self.event_type,
|
|
233
|
+
"event_hmac": self.event_hmac,
|
|
234
|
+
"chain_position": self.chain_position,
|
|
235
|
+
"signer_id": self.signer_id,
|
|
236
|
+
"signed_at": self.signed_at,
|
|
237
|
+
}
|
|
238
|
+
if self.prev_chain_hmac is not None:
|
|
239
|
+
d["prev_chain_hmac"] = self.prev_chain_hmac
|
|
240
|
+
return d
|
|
241
|
+
|
|
242
|
+
@classmethod
|
|
243
|
+
def from_dict(cls, data: dict[str, Any]) -> AuditChainPayload:
|
|
244
|
+
"""Deserialise from a plain ``dict``."""
|
|
245
|
+
return cls(
|
|
246
|
+
event_id=data["event_id"],
|
|
247
|
+
event_type=data["event_type"],
|
|
248
|
+
event_hmac=data["event_hmac"],
|
|
249
|
+
chain_position=int(data["chain_position"]),
|
|
250
|
+
signer_id=data["signer_id"],
|
|
251
|
+
signed_at=data["signed_at"],
|
|
252
|
+
prev_chain_hmac=data.get("prev_chain_hmac"),
|
|
253
|
+
)
|
|
@@ -0,0 +1,209 @@
|
|
|
1
|
+
"""spanforge.namespaces.cache — Cache payload types (RFC-0001).
|
|
2
|
+
|
|
3
|
+
Classes
|
|
4
|
+
-------
|
|
5
|
+
CacheHitPayload llm.cache.hit
|
|
6
|
+
CacheMissPayload llm.cache.miss
|
|
7
|
+
CacheEvictedPayload llm.cache.evicted
|
|
8
|
+
CacheWrittenPayload llm.cache.written
|
|
9
|
+
"""
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
from dataclasses import dataclass
|
|
13
|
+
from typing import Any
|
|
14
|
+
|
|
15
|
+
from spanforge.namespaces.trace import CostBreakdown, ModelInfo, TokenUsage
|
|
16
|
+
|
|
17
|
+
__all__ = [
|
|
18
|
+
"CacheEvictedPayload",
|
|
19
|
+
"CacheHitPayload",
|
|
20
|
+
"CacheMissPayload",
|
|
21
|
+
"CacheWrittenPayload",
|
|
22
|
+
]
|
|
23
|
+
|
|
24
|
+
_VALID_EVICTION_REASONS = frozenset({
|
|
25
|
+
"ttl_expired", "lru_eviction", "manual_invalidation",
|
|
26
|
+
"capacity_exceeded", "schema_upgrade",
|
|
27
|
+
})
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
@dataclass
|
|
31
|
+
class CacheHitPayload:
|
|
32
|
+
"""Payload for llm.cache.hit — semantic cache lookup succeeded."""
|
|
33
|
+
|
|
34
|
+
key_hash: str
|
|
35
|
+
namespace: str
|
|
36
|
+
similarity_score: float
|
|
37
|
+
ttl_remaining_seconds: int | None = None
|
|
38
|
+
cached_model: ModelInfo | None = None
|
|
39
|
+
cost_saved: CostBreakdown | None = None
|
|
40
|
+
tokens_saved: TokenUsage | None = None
|
|
41
|
+
lookup_duration_ms: float | None = None
|
|
42
|
+
|
|
43
|
+
def __post_init__(self) -> None:
|
|
44
|
+
if not self.key_hash:
|
|
45
|
+
raise ValueError("CacheHitPayload.key_hash must be non-empty")
|
|
46
|
+
if not self.namespace:
|
|
47
|
+
raise ValueError("CacheHitPayload.namespace must be non-empty")
|
|
48
|
+
if not (0.0 <= self.similarity_score <= 1.0):
|
|
49
|
+
raise ValueError("CacheHitPayload.similarity_score must be in [0,1]")
|
|
50
|
+
|
|
51
|
+
def to_dict(self) -> dict[str, Any]:
|
|
52
|
+
"""Serialise the payload to a plain ``dict``."""
|
|
53
|
+
d: dict[str, Any] = {
|
|
54
|
+
"key_hash": self.key_hash,
|
|
55
|
+
"namespace": self.namespace,
|
|
56
|
+
"similarity_score": self.similarity_score,
|
|
57
|
+
}
|
|
58
|
+
if self.ttl_remaining_seconds is not None:
|
|
59
|
+
d["ttl_remaining_seconds"] = self.ttl_remaining_seconds
|
|
60
|
+
if self.cached_model is not None:
|
|
61
|
+
d["cached_model"] = self.cached_model.to_dict()
|
|
62
|
+
if self.cost_saved is not None:
|
|
63
|
+
d["cost_saved"] = self.cost_saved.to_dict()
|
|
64
|
+
if self.tokens_saved is not None:
|
|
65
|
+
d["tokens_saved"] = self.tokens_saved.to_dict()
|
|
66
|
+
if self.lookup_duration_ms is not None:
|
|
67
|
+
d["lookup_duration_ms"] = self.lookup_duration_ms
|
|
68
|
+
return d
|
|
69
|
+
|
|
70
|
+
@classmethod
|
|
71
|
+
def from_dict(cls, data: dict[str, Any]) -> CacheHitPayload:
|
|
72
|
+
"""Deserialise from a plain ``dict``."""
|
|
73
|
+
return cls(
|
|
74
|
+
key_hash=data["key_hash"],
|
|
75
|
+
namespace=data["namespace"],
|
|
76
|
+
similarity_score=float(data["similarity_score"]),
|
|
77
|
+
ttl_remaining_seconds=int(data["ttl_remaining_seconds"]) if "ttl_remaining_seconds" in data else None, # noqa: E501
|
|
78
|
+
cached_model=ModelInfo.from_dict(data["cached_model"]) if "cached_model" in data else None, # noqa: E501
|
|
79
|
+
cost_saved=CostBreakdown.from_dict(data["cost_saved"]) if "cost_saved" in data else None, # noqa: E501
|
|
80
|
+
tokens_saved=TokenUsage.from_dict(data["tokens_saved"]) if "tokens_saved" in data else None, # noqa: E501
|
|
81
|
+
lookup_duration_ms=float(data["lookup_duration_ms"]) if "lookup_duration_ms" in data else None, # noqa: E501
|
|
82
|
+
)
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
@dataclass
|
|
86
|
+
class CacheMissPayload:
|
|
87
|
+
"""Payload for llm.cache.miss — semantic cache lookup failed."""
|
|
88
|
+
|
|
89
|
+
key_hash: str
|
|
90
|
+
namespace: str
|
|
91
|
+
best_similarity_score: float | None = None
|
|
92
|
+
similarity_threshold: float | None = None
|
|
93
|
+
lookup_duration_ms: float | None = None
|
|
94
|
+
|
|
95
|
+
def __post_init__(self) -> None:
|
|
96
|
+
if not self.key_hash:
|
|
97
|
+
raise ValueError("CacheMissPayload.key_hash must be non-empty")
|
|
98
|
+
if not self.namespace:
|
|
99
|
+
raise ValueError("CacheMissPayload.namespace must be non-empty")
|
|
100
|
+
|
|
101
|
+
def to_dict(self) -> dict[str, Any]:
|
|
102
|
+
"""Serialise the payload to a plain ``dict``."""
|
|
103
|
+
d: dict[str, Any] = {"key_hash": self.key_hash, "namespace": self.namespace}
|
|
104
|
+
if self.best_similarity_score is not None:
|
|
105
|
+
d["best_similarity_score"] = self.best_similarity_score
|
|
106
|
+
if self.similarity_threshold is not None:
|
|
107
|
+
d["similarity_threshold"] = self.similarity_threshold
|
|
108
|
+
if self.lookup_duration_ms is not None:
|
|
109
|
+
d["lookup_duration_ms"] = self.lookup_duration_ms
|
|
110
|
+
return d
|
|
111
|
+
|
|
112
|
+
@classmethod
|
|
113
|
+
def from_dict(cls, data: dict[str, Any]) -> CacheMissPayload:
|
|
114
|
+
"""Deserialise from a plain ``dict``."""
|
|
115
|
+
return cls(
|
|
116
|
+
key_hash=data["key_hash"],
|
|
117
|
+
namespace=data["namespace"],
|
|
118
|
+
best_similarity_score=float(data["best_similarity_score"]) if "best_similarity_score" in data else None, # noqa: E501
|
|
119
|
+
similarity_threshold=float(data["similarity_threshold"]) if "similarity_threshold" in data else None, # noqa: E501
|
|
120
|
+
lookup_duration_ms=float(data["lookup_duration_ms"]) if "lookup_duration_ms" in data else None, # noqa: E501
|
|
121
|
+
)
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
@dataclass
|
|
125
|
+
class CacheEvictedPayload:
|
|
126
|
+
"""Payload for llm.cache.evicted — a cache entry was removed."""
|
|
127
|
+
|
|
128
|
+
key_hash: str
|
|
129
|
+
namespace: str
|
|
130
|
+
eviction_reason: str
|
|
131
|
+
entry_age_seconds: int | None = None
|
|
132
|
+
|
|
133
|
+
def __post_init__(self) -> None:
|
|
134
|
+
if not self.key_hash:
|
|
135
|
+
raise ValueError("CacheEvictedPayload.key_hash must be non-empty")
|
|
136
|
+
if not self.namespace:
|
|
137
|
+
raise ValueError("CacheEvictedPayload.namespace must be non-empty")
|
|
138
|
+
if self.eviction_reason not in _VALID_EVICTION_REASONS:
|
|
139
|
+
raise ValueError(
|
|
140
|
+
f"CacheEvictedPayload.eviction_reason must be one of {sorted(_VALID_EVICTION_REASONS)}" # noqa: E501
|
|
141
|
+
)
|
|
142
|
+
|
|
143
|
+
def to_dict(self) -> dict[str, Any]:
|
|
144
|
+
"""Serialise the payload to a plain ``dict``."""
|
|
145
|
+
d: dict[str, Any] = {
|
|
146
|
+
"key_hash": self.key_hash,
|
|
147
|
+
"namespace": self.namespace,
|
|
148
|
+
"eviction_reason": self.eviction_reason,
|
|
149
|
+
}
|
|
150
|
+
if self.entry_age_seconds is not None:
|
|
151
|
+
d["entry_age_seconds"] = self.entry_age_seconds
|
|
152
|
+
return d
|
|
153
|
+
|
|
154
|
+
@classmethod
|
|
155
|
+
def from_dict(cls, data: dict[str, Any]) -> CacheEvictedPayload:
|
|
156
|
+
"""Deserialise from a plain ``dict``."""
|
|
157
|
+
return cls(
|
|
158
|
+
key_hash=data["key_hash"],
|
|
159
|
+
namespace=data["namespace"],
|
|
160
|
+
eviction_reason=data["eviction_reason"],
|
|
161
|
+
entry_age_seconds=int(data["entry_age_seconds"]) if "entry_age_seconds" in data else None, # noqa: E501
|
|
162
|
+
)
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
@dataclass
|
|
166
|
+
class CacheWrittenPayload:
|
|
167
|
+
"""Payload for llm.cache.written — a response was written to cache."""
|
|
168
|
+
|
|
169
|
+
key_hash: str
|
|
170
|
+
namespace: str
|
|
171
|
+
ttl_seconds: int
|
|
172
|
+
model: ModelInfo | None = None
|
|
173
|
+
response_token_count: int | None = None
|
|
174
|
+
write_duration_ms: float | None = None
|
|
175
|
+
|
|
176
|
+
def __post_init__(self) -> None:
|
|
177
|
+
if not self.key_hash:
|
|
178
|
+
raise ValueError("CacheWrittenPayload.key_hash must be non-empty")
|
|
179
|
+
if not self.namespace:
|
|
180
|
+
raise ValueError("CacheWrittenPayload.namespace must be non-empty")
|
|
181
|
+
if not isinstance(self.ttl_seconds, int) or self.ttl_seconds < 0:
|
|
182
|
+
raise ValueError("CacheWrittenPayload.ttl_seconds must be a non-negative int")
|
|
183
|
+
|
|
184
|
+
def to_dict(self) -> dict[str, Any]:
|
|
185
|
+
"""Serialise the payload to a plain ``dict``."""
|
|
186
|
+
d: dict[str, Any] = {
|
|
187
|
+
"key_hash": self.key_hash,
|
|
188
|
+
"namespace": self.namespace,
|
|
189
|
+
"ttl_seconds": self.ttl_seconds,
|
|
190
|
+
}
|
|
191
|
+
if self.model is not None:
|
|
192
|
+
d["model"] = self.model.to_dict()
|
|
193
|
+
if self.response_token_count is not None:
|
|
194
|
+
d["response_token_count"] = self.response_token_count
|
|
195
|
+
if self.write_duration_ms is not None:
|
|
196
|
+
d["write_duration_ms"] = self.write_duration_ms
|
|
197
|
+
return d
|
|
198
|
+
|
|
199
|
+
@classmethod
|
|
200
|
+
def from_dict(cls, data: dict[str, Any]) -> CacheWrittenPayload:
|
|
201
|
+
"""Deserialise from a plain ``dict``."""
|
|
202
|
+
return cls(
|
|
203
|
+
key_hash=data["key_hash"],
|
|
204
|
+
namespace=data["namespace"],
|
|
205
|
+
ttl_seconds=int(data["ttl_seconds"]),
|
|
206
|
+
model=ModelInfo.from_dict(data["model"]) if "model" in data else None,
|
|
207
|
+
response_token_count=int(data["response_token_count"]) if "response_token_count" in data else None, # noqa: E501
|
|
208
|
+
write_duration_ms=float(data["write_duration_ms"]) if "write_duration_ms" in data else None, # noqa: E501
|
|
209
|
+
)
|