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,69 @@
1
+ """spanforge.namespaces.latency \u2014 Latency namespace payload types (RFC-0001 SPANFORGE).
2
+
3
+ Classes
4
+ -------
5
+ LatencyPayload latency.sample / latency.sla_breach
6
+ """
7
+ from __future__ import annotations
8
+
9
+ from dataclasses import dataclass
10
+ from typing import Any
11
+
12
+ __all__ = ["LatencyPayload"]
13
+
14
+
15
+ @dataclass
16
+ class LatencyPayload:
17
+ """RFC-0001 SPANFORGE \u2014 payload for latency.* events.
18
+
19
+ Captures end-to-end response time, per-step breakdown, and SLA compliance
20
+ tracking (T \u2014 Traceability / S \u2014 Safety Guardrails).
21
+ """
22
+
23
+ agent_id: str
24
+ operation: str
25
+ latency_ms: float
26
+ sla_target_ms: float
27
+ sla_met: bool
28
+ p50_ms: float | None = None
29
+ p95_ms: float | None = None
30
+ p99_ms: float | None = None
31
+
32
+ def __post_init__(self) -> None:
33
+ if not self.agent_id:
34
+ raise ValueError("LatencyPayload.agent_id must be non-empty")
35
+ if not self.operation:
36
+ raise ValueError("LatencyPayload.operation must be non-empty")
37
+ if self.latency_ms < 0:
38
+ raise ValueError("LatencyPayload.latency_ms must be >= 0")
39
+ if self.sla_target_ms <= 0:
40
+ raise ValueError("LatencyPayload.sla_target_ms must be > 0")
41
+
42
+ def to_dict(self) -> dict[str, Any]:
43
+ d: dict[str, Any] = {
44
+ "agent_id": self.agent_id,
45
+ "operation": self.operation,
46
+ "latency_ms": self.latency_ms,
47
+ "sla_target_ms": self.sla_target_ms,
48
+ "sla_met": self.sla_met,
49
+ }
50
+ if self.p50_ms is not None:
51
+ d["p50_ms"] = self.p50_ms
52
+ if self.p95_ms is not None:
53
+ d["p95_ms"] = self.p95_ms
54
+ if self.p99_ms is not None:
55
+ d["p99_ms"] = self.p99_ms
56
+ return d
57
+
58
+ @classmethod
59
+ def from_dict(cls, data: dict[str, Any]) -> LatencyPayload:
60
+ return cls(
61
+ agent_id=data["agent_id"],
62
+ operation=data["operation"],
63
+ latency_ms=float(data["latency_ms"]),
64
+ sla_target_ms=float(data["sla_target_ms"]),
65
+ sla_met=bool(data["sla_met"]),
66
+ p50_ms=data.get("p50_ms"),
67
+ p95_ms=data.get("p95_ms"),
68
+ p99_ms=data.get("p99_ms"),
69
+ )
@@ -0,0 +1,185 @@
1
+ """spanforge.namespaces.prompt — Prompt payload types (RFC-0001).
2
+
3
+ Classes
4
+ -------
5
+ PromptRenderedPayload llm.prompt.rendered
6
+ PromptTemplateLoadedPayload llm.prompt.template.loaded
7
+ PromptVersionChangedPayload llm.prompt.version.changed
8
+ """
9
+ from __future__ import annotations
10
+
11
+ from dataclasses import dataclass, field
12
+ from typing import Any
13
+
14
+ __all__ = [
15
+ "PromptRenderedPayload",
16
+ "PromptTemplateLoadedPayload",
17
+ "PromptVersionChangedPayload",
18
+ ]
19
+
20
+ _VALID_SOURCES = frozenset({"registry", "file", "database", "remote_url", "inline"})
21
+ _SHA256_HEX_LEN = 64 # SHA-256 hex digest length (characters)
22
+
23
+
24
+ @dataclass
25
+ class PromptRenderedPayload:
26
+ """RFC-0001 — A prompt template was rendered with variables.
27
+
28
+ ``rendered_hash`` is the SHA-256 of the fully-rendered prompt text.
29
+ The rendered text MUST NOT be stored.
30
+ """
31
+
32
+ template_id: str
33
+ version: str
34
+ rendered_hash: str # 64 lowercase hex chars, SHA-256 of rendered text
35
+ variable_count: int | None = None
36
+ variable_names: list[str] = field(default_factory=list)
37
+ char_count: int | None = None
38
+ token_estimate: int | None = None
39
+ language: str | None = None
40
+ span_id: str | None = None
41
+
42
+ def __post_init__(self) -> None:
43
+ if not self.template_id:
44
+ raise ValueError("PromptRenderedPayload.template_id must be non-empty")
45
+ if not self.version:
46
+ raise ValueError("PromptRenderedPayload.version must be non-empty")
47
+ if not self.rendered_hash or len(self.rendered_hash) != _SHA256_HEX_LEN:
48
+ raise ValueError("PromptRenderedPayload.rendered_hash must be 64 hex chars (SHA-256)")
49
+
50
+ def to_dict(self) -> dict[str, Any]:
51
+ """Serialise the payload to a plain ``dict``."""
52
+ d: dict[str, Any] = {
53
+ "template_id": self.template_id,
54
+ "version": self.version,
55
+ "rendered_hash": self.rendered_hash,
56
+ }
57
+ if self.variable_count is not None:
58
+ d["variable_count"] = self.variable_count
59
+ if self.variable_names:
60
+ d["variable_names"] = list(self.variable_names)
61
+ if self.char_count is not None:
62
+ d["char_count"] = self.char_count
63
+ if self.token_estimate is not None:
64
+ d["token_estimate"] = self.token_estimate
65
+ if self.language is not None:
66
+ d["language"] = self.language
67
+ if self.span_id is not None:
68
+ d["span_id"] = self.span_id
69
+ return d
70
+
71
+ @classmethod
72
+ def from_dict(cls, data: dict[str, Any]) -> PromptRenderedPayload:
73
+ """Deserialise from a plain ``dict``."""
74
+ return cls(
75
+ template_id=data["template_id"],
76
+ version=data["version"],
77
+ rendered_hash=data["rendered_hash"],
78
+ variable_count=int(data["variable_count"]) if "variable_count" in data else None,
79
+ variable_names=list(data.get("variable_names", [])),
80
+ char_count=int(data["char_count"]) if "char_count" in data else None,
81
+ token_estimate=int(data["token_estimate"]) if "token_estimate" in data else None,
82
+ language=data.get("language"),
83
+ span_id=data.get("span_id"),
84
+ )
85
+
86
+
87
+ @dataclass
88
+ class PromptTemplateLoadedPayload:
89
+ """RFC-0001 — A prompt template was loaded from a source."""
90
+
91
+ template_id: str
92
+ version: str
93
+ source: str # "registry"|"file"|"database"|"remote_url"|"inline"
94
+ template_hash: str | None = None # 64 hex chars
95
+ load_duration_ms: float | None = None
96
+ cache_hit: bool | None = None
97
+
98
+ def __post_init__(self) -> None:
99
+ if not self.template_id:
100
+ raise ValueError("PromptTemplateLoadedPayload.template_id must be non-empty")
101
+ if not self.version:
102
+ raise ValueError("PromptTemplateLoadedPayload.version must be non-empty")
103
+ if self.source not in _VALID_SOURCES:
104
+ raise ValueError(f"PromptTemplateLoadedPayload.source must be one of {sorted(_VALID_SOURCES)}") # noqa: E501
105
+ if self.template_hash is not None and len(self.template_hash) != _SHA256_HEX_LEN:
106
+ raise ValueError("PromptTemplateLoadedPayload.template_hash must be 64 hex chars")
107
+
108
+ def to_dict(self) -> dict[str, Any]:
109
+ """Serialise the payload to a plain ``dict``."""
110
+ d: dict[str, Any] = {
111
+ "template_id": self.template_id,
112
+ "version": self.version,
113
+ "source": self.source,
114
+ }
115
+ if self.template_hash is not None:
116
+ d["template_hash"] = self.template_hash
117
+ if self.load_duration_ms is not None:
118
+ d["load_duration_ms"] = self.load_duration_ms
119
+ if self.cache_hit is not None:
120
+ d["cache_hit"] = self.cache_hit
121
+ return d
122
+
123
+ @classmethod
124
+ def from_dict(cls, data: dict[str, Any]) -> PromptTemplateLoadedPayload:
125
+ """Deserialise from a plain ``dict``."""
126
+ return cls(
127
+ template_id=data["template_id"],
128
+ version=data["version"],
129
+ source=data["source"],
130
+ template_hash=data.get("template_hash"),
131
+ load_duration_ms=float(data["load_duration_ms"]) if "load_duration_ms" in data else None, # noqa: E501
132
+ cache_hit=bool(data["cache_hit"]) if "cache_hit" in data else None,
133
+ )
134
+
135
+
136
+ @dataclass
137
+ class PromptVersionChangedPayload:
138
+ """RFC-0001 — A prompt template was promoted to a new version."""
139
+
140
+ template_id: str
141
+ previous_version: str
142
+ new_version: str
143
+ change_reason: str
144
+ changed_by: str | None = None
145
+ previous_hash: str | None = None # 64 hex chars
146
+ new_hash: str | None = None # 64 hex chars
147
+
148
+ def __post_init__(self) -> None:
149
+ if not self.template_id:
150
+ raise ValueError("PromptVersionChangedPayload.template_id must be non-empty")
151
+ if not self.previous_version:
152
+ raise ValueError("PromptVersionChangedPayload.previous_version must be non-empty")
153
+ if not self.new_version:
154
+ raise ValueError("PromptVersionChangedPayload.new_version must be non-empty")
155
+ if not self.change_reason:
156
+ raise ValueError("PromptVersionChangedPayload.change_reason must be non-empty")
157
+
158
+ def to_dict(self) -> dict[str, Any]:
159
+ """Serialise the payload to a plain ``dict``."""
160
+ d: dict[str, Any] = {
161
+ "template_id": self.template_id,
162
+ "previous_version": self.previous_version,
163
+ "new_version": self.new_version,
164
+ "change_reason": self.change_reason,
165
+ }
166
+ if self.changed_by is not None:
167
+ d["changed_by"] = self.changed_by
168
+ if self.previous_hash is not None:
169
+ d["previous_hash"] = self.previous_hash
170
+ if self.new_hash is not None:
171
+ d["new_hash"] = self.new_hash
172
+ return d
173
+
174
+ @classmethod
175
+ def from_dict(cls, data: dict[str, Any]) -> PromptVersionChangedPayload:
176
+ """Deserialise from a plain ``dict``."""
177
+ return cls(
178
+ template_id=data["template_id"],
179
+ previous_version=data["previous_version"],
180
+ new_version=data["new_version"],
181
+ change_reason=data["change_reason"],
182
+ changed_by=data.get("changed_by"),
183
+ previous_hash=data.get("previous_hash"),
184
+ new_hash=data.get("new_hash"),
185
+ )
@@ -0,0 +1,172 @@
1
+ """spanforge.namespaces.redact — Redaction payload types (RFC-0001).
2
+
3
+ Classes
4
+ -------
5
+ RedactPiiDetectedPayload llm.redact.pii.detected
6
+ RedactPhiDetectedPayload llm.redact.phi.detected
7
+ RedactAppliedPayload llm.redact.applied
8
+ """
9
+ from __future__ import annotations
10
+
11
+ from dataclasses import dataclass, field
12
+ from typing import Any
13
+
14
+ __all__ = [
15
+ "RedactAppliedPayload",
16
+ "RedactPhiDetectedPayload",
17
+ "RedactPiiDetectedPayload",
18
+ ]
19
+
20
+ _VALID_SENSITIVITY_LEVELS = frozenset({"LOW", "MEDIUM", "HIGH", "PII", "PHI"})
21
+
22
+
23
+ @dataclass
24
+ class RedactPiiDetectedPayload:
25
+ """RFC-0001 — PII was detected in an LLM input or output field."""
26
+
27
+ detected_categories: list[str] # minItems=1 — e.g. ["email", "phone"]
28
+ field_names: list[str] # minItems=1 — field paths where PII found
29
+ sensitivity_level: str # "LOW"|"MEDIUM"|"HIGH"|"PII"|"PHI"
30
+ detection_count: int | None = None
31
+ detector: str | None = None
32
+ subject_event_id: str | None = None
33
+
34
+ def __post_init__(self) -> None:
35
+ if not self.detected_categories:
36
+ raise ValueError("RedactPiiDetectedPayload.detected_categories must be non-empty")
37
+ if not self.field_names:
38
+ raise ValueError("RedactPiiDetectedPayload.field_names must be non-empty")
39
+ if self.sensitivity_level not in _VALID_SENSITIVITY_LEVELS:
40
+ raise ValueError(
41
+ f"RedactPiiDetectedPayload.sensitivity_level must be one of {sorted(_VALID_SENSITIVITY_LEVELS)}" # noqa: E501
42
+ )
43
+
44
+ def to_dict(self) -> dict[str, Any]:
45
+ """Serialise the payload to a plain ``dict``."""
46
+ d: dict[str, Any] = {
47
+ "detected_categories": list(self.detected_categories),
48
+ "field_names": list(self.field_names),
49
+ "sensitivity_level": self.sensitivity_level,
50
+ }
51
+ if self.detection_count is not None:
52
+ d["detection_count"] = self.detection_count
53
+ if self.detector is not None:
54
+ d["detector"] = self.detector
55
+ if self.subject_event_id is not None:
56
+ d["subject_event_id"] = self.subject_event_id
57
+ return d
58
+
59
+ @classmethod
60
+ def from_dict(cls, data: dict[str, Any]) -> RedactPiiDetectedPayload:
61
+ """Deserialise from a plain ``dict``."""
62
+ return cls(
63
+ detected_categories=list(data["detected_categories"]),
64
+ field_names=list(data["field_names"]),
65
+ sensitivity_level=data["sensitivity_level"],
66
+ detection_count=int(data["detection_count"]) if "detection_count" in data else None,
67
+ detector=data.get("detector"),
68
+ subject_event_id=data.get("subject_event_id"),
69
+ )
70
+
71
+
72
+ @dataclass
73
+ class RedactPhiDetectedPayload:
74
+ """RFC-0001 — PHI was detected (HIPAA-covered health information).
75
+
76
+ ``sensitivity_level`` MUST always be ``"PHI"`` for this payload type.
77
+ """
78
+
79
+ detected_categories: list[str]
80
+ field_names: list[str]
81
+ sensitivity_level: str = "PHI" # MUST be "PHI"
82
+ detection_count: int | None = None
83
+ detector: str | None = None
84
+ subject_event_id: str | None = None
85
+ hipaa_covered: bool | None = None
86
+
87
+ def __post_init__(self) -> None:
88
+ if not self.detected_categories:
89
+ raise ValueError("RedactPhiDetectedPayload.detected_categories must be non-empty")
90
+ if not self.field_names:
91
+ raise ValueError("RedactPhiDetectedPayload.field_names must be non-empty")
92
+ if self.sensitivity_level != "PHI":
93
+ raise ValueError("RedactPhiDetectedPayload.sensitivity_level MUST be 'PHI'")
94
+
95
+ def to_dict(self) -> dict[str, Any]:
96
+ """Serialise the payload to a plain ``dict``."""
97
+ d: dict[str, Any] = {
98
+ "detected_categories": list(self.detected_categories),
99
+ "field_names": list(self.field_names),
100
+ "sensitivity_level": self.sensitivity_level,
101
+ }
102
+ if self.detection_count is not None:
103
+ d["detection_count"] = self.detection_count
104
+ if self.detector is not None:
105
+ d["detector"] = self.detector
106
+ if self.subject_event_id is not None:
107
+ d["subject_event_id"] = self.subject_event_id
108
+ if self.hipaa_covered is not None:
109
+ d["hipaa_covered"] = self.hipaa_covered
110
+ return d
111
+
112
+ @classmethod
113
+ def from_dict(cls, data: dict[str, Any]) -> RedactPhiDetectedPayload:
114
+ """Deserialise from a plain ``dict``."""
115
+ return cls(
116
+ detected_categories=list(data["detected_categories"]),
117
+ field_names=list(data["field_names"]),
118
+ sensitivity_level=data.get("sensitivity_level", "PHI"),
119
+ detection_count=int(data["detection_count"]) if "detection_count" in data else None,
120
+ detector=data.get("detector"),
121
+ subject_event_id=data.get("subject_event_id"),
122
+ hipaa_covered=bool(data["hipaa_covered"]) if "hipaa_covered" in data else None,
123
+ )
124
+
125
+
126
+ @dataclass
127
+ class RedactAppliedPayload:
128
+ """RFC-0001 — A redaction policy was applied to one or more fields."""
129
+
130
+ policy_min_sensitivity: str # "LOW"|"MEDIUM"|"HIGH"|"PII"|"PHI"
131
+ redacted_by: str
132
+ redacted_count: int
133
+ redacted_field_names: list[str] = field(default_factory=list)
134
+ subject_event_id: str | None = None
135
+ verified: bool | None = None
136
+
137
+ def __post_init__(self) -> None:
138
+ if self.policy_min_sensitivity not in _VALID_SENSITIVITY_LEVELS:
139
+ raise ValueError(
140
+ f"RedactAppliedPayload.policy_min_sensitivity must be one of {sorted(_VALID_SENSITIVITY_LEVELS)}" # noqa: E501
141
+ )
142
+ if not isinstance(self.redacted_by, str) or not self.redacted_by:
143
+ raise ValueError("RedactAppliedPayload.redacted_by must be non-empty")
144
+ if not isinstance(self.redacted_count, int) or self.redacted_count < 0:
145
+ raise ValueError("RedactAppliedPayload.redacted_count must be a non-negative int")
146
+
147
+ def to_dict(self) -> dict[str, Any]:
148
+ """Serialise the payload to a plain ``dict``."""
149
+ d: dict[str, Any] = {
150
+ "policy_min_sensitivity": self.policy_min_sensitivity,
151
+ "redacted_by": self.redacted_by,
152
+ "redacted_count": self.redacted_count,
153
+ }
154
+ if self.redacted_field_names:
155
+ d["redacted_field_names"] = list(self.redacted_field_names)
156
+ if self.subject_event_id is not None:
157
+ d["subject_event_id"] = self.subject_event_id
158
+ if self.verified is not None:
159
+ d["verified"] = self.verified
160
+ return d
161
+
162
+ @classmethod
163
+ def from_dict(cls, data: dict[str, Any]) -> RedactAppliedPayload:
164
+ """Deserialise from a plain ``dict``."""
165
+ return cls(
166
+ policy_min_sensitivity=data["policy_min_sensitivity"],
167
+ redacted_by=data["redacted_by"],
168
+ redacted_count=int(data["redacted_count"]),
169
+ redacted_field_names=list(data.get("redacted_field_names", [])),
170
+ subject_event_id=data.get("subject_event_id"),
171
+ verified=bool(data["verified"]) if "verified" in data else None,
172
+ )
@@ -0,0 +1,197 @@
1
+ """spanforge.namespaces.template — Template payload types (RFC-0001).
2
+
3
+ Classes
4
+ -------
5
+ TemplateRegisteredPayload llm.template.registered
6
+ TemplateVariableBoundPayload llm.template.variable.bound
7
+ TemplateValidationFailedPayload llm.template.validation.failed
8
+ """
9
+ from __future__ import annotations
10
+
11
+ from dataclasses import dataclass, field
12
+ from typing import Any
13
+
14
+ __all__ = [
15
+ "TemplateRegisteredPayload",
16
+ "TemplateValidationFailedPayload",
17
+ "TemplateVariableBoundPayload",
18
+ ]
19
+
20
+ _VALID_VALUE_TYPES = frozenset({
21
+ "string", "integer", "float", "boolean", "array", "object", "null"
22
+ })
23
+ _VALID_FAILURE_TYPES = frozenset({
24
+ "missing_variable", "type_mismatch", "hash_mismatch",
25
+ "version_not_found", "syntax_error", "schema_violation"
26
+ })
27
+
28
+ _SHA256_HEX_LEN = 64 # SHA-256 hex digest length (characters)
29
+
30
+
31
+ @dataclass
32
+ class TemplateRegisteredPayload:
33
+ """RFC-0001 — A prompt template was registered in the registry."""
34
+
35
+ template_id: str
36
+ version: str
37
+ template_hash: str # 64 lowercase hex chars, SHA-256 of template source
38
+ variable_names: list[str] = field(default_factory=list)
39
+ variable_count: int | None = None
40
+ language: str | None = None
41
+ char_count: int | None = None
42
+ registered_by: str | None = None
43
+ is_active: bool | None = None
44
+ tags: dict[str, str] | None = None
45
+
46
+ def __post_init__(self) -> None:
47
+ if not self.template_id:
48
+ raise ValueError("TemplateRegisteredPayload.template_id must be non-empty")
49
+ if not self.version:
50
+ raise ValueError("TemplateRegisteredPayload.version must be non-empty")
51
+ if not self.template_hash or len(self.template_hash) != _SHA256_HEX_LEN:
52
+ raise ValueError("TemplateRegisteredPayload.template_hash must be 64 hex chars (SHA-256)") # noqa: E501
53
+
54
+ def to_dict(self) -> dict[str, Any]:
55
+ """Serialise the payload to a plain ``dict``."""
56
+ d: dict[str, Any] = {
57
+ "template_id": self.template_id,
58
+ "version": self.version,
59
+ "template_hash": self.template_hash,
60
+ }
61
+ if self.variable_names:
62
+ d["variable_names"] = list(self.variable_names)
63
+ if self.variable_count is not None:
64
+ d["variable_count"] = self.variable_count
65
+ if self.language is not None:
66
+ d["language"] = self.language
67
+ if self.char_count is not None:
68
+ d["char_count"] = self.char_count
69
+ if self.registered_by is not None:
70
+ d["registered_by"] = self.registered_by
71
+ if self.is_active is not None:
72
+ d["is_active"] = self.is_active
73
+ if self.tags is not None:
74
+ d["tags"] = dict(self.tags)
75
+ return d
76
+
77
+ @classmethod
78
+ def from_dict(cls, data: dict[str, Any]) -> TemplateRegisteredPayload:
79
+ """Deserialise from a plain ``dict``."""
80
+ return cls(
81
+ template_id=data["template_id"],
82
+ version=data["version"],
83
+ template_hash=data["template_hash"],
84
+ variable_names=list(data.get("variable_names", [])),
85
+ variable_count=int(data["variable_count"]) if "variable_count" in data else None,
86
+ language=data.get("language"),
87
+ char_count=int(data["char_count"]) if "char_count" in data else None,
88
+ registered_by=data.get("registered_by"),
89
+ is_active=bool(data["is_active"]) if "is_active" in data else None,
90
+ tags=dict(data["tags"]) if "tags" in data else None,
91
+ )
92
+
93
+
94
+ @dataclass
95
+ class TemplateVariableBoundPayload:
96
+ """RFC-0001 — A variable was bound to a value for template rendering.
97
+
98
+ ``value_hash`` stores a SHA-256 hash of the value. For sensitive variables,
99
+ the raw value MUST NOT be stored.
100
+ """
101
+
102
+ template_id: str
103
+ version: str
104
+ variable_name: str
105
+ value_type: str | None = None # "string"|"integer"|"float"|"boolean"|"array"|"object"|"null"
106
+ value_length: int | None = None
107
+ value_hash: str | None = None # 64 hex chars, SHA-256
108
+ is_sensitive: bool | None = None
109
+ span_id: str | None = None
110
+
111
+ def __post_init__(self) -> None:
112
+ if not self.template_id:
113
+ raise ValueError("TemplateVariableBoundPayload.template_id must be non-empty")
114
+ if not self.version:
115
+ raise ValueError("TemplateVariableBoundPayload.version must be non-empty")
116
+ if not self.variable_name:
117
+ raise ValueError("TemplateVariableBoundPayload.variable_name must be non-empty")
118
+ if self.value_type is not None and self.value_type not in _VALID_VALUE_TYPES:
119
+ raise ValueError(f"TemplateVariableBoundPayload.value_type must be one of {sorted(_VALID_VALUE_TYPES)}") # noqa: E501
120
+ if self.value_hash is not None and len(self.value_hash) != _SHA256_HEX_LEN:
121
+ raise ValueError("TemplateVariableBoundPayload.value_hash must be 64 hex chars (SHA-256)") # noqa: E501
122
+
123
+ def to_dict(self) -> dict[str, Any]:
124
+ """Serialise the payload to a plain ``dict``."""
125
+ d: dict[str, Any] = {
126
+ "template_id": self.template_id,
127
+ "version": self.version,
128
+ "variable_name": self.variable_name,
129
+ }
130
+ if self.value_type is not None:
131
+ d["value_type"] = self.value_type
132
+ if self.value_length is not None:
133
+ d["value_length"] = self.value_length
134
+ if self.value_hash is not None:
135
+ d["value_hash"] = self.value_hash
136
+ if self.is_sensitive is not None:
137
+ d["is_sensitive"] = self.is_sensitive
138
+ if self.span_id is not None:
139
+ d["span_id"] = self.span_id
140
+ return d
141
+
142
+ @classmethod
143
+ def from_dict(cls, data: dict[str, Any]) -> TemplateVariableBoundPayload:
144
+ """Deserialise from a plain ``dict``."""
145
+ return cls(
146
+ template_id=data["template_id"],
147
+ version=data["version"],
148
+ variable_name=data["variable_name"],
149
+ value_type=data.get("value_type"),
150
+ value_length=int(data["value_length"]) if "value_length" in data else None,
151
+ value_hash=data.get("value_hash"),
152
+ is_sensitive=bool(data["is_sensitive"]) if "is_sensitive" in data else None,
153
+ span_id=data.get("span_id"),
154
+ )
155
+
156
+
157
+ @dataclass
158
+ class TemplateValidationFailedPayload:
159
+ """RFC-0001 — Template validation failed during rendering or registration."""
160
+
161
+ template_id: str
162
+ version: str
163
+ failure_reason: str
164
+ failure_type: str | None = None
165
+
166
+ def __post_init__(self) -> None:
167
+ if not self.template_id:
168
+ raise ValueError("TemplateValidationFailedPayload.template_id must be non-empty")
169
+ if not self.version:
170
+ raise ValueError("TemplateValidationFailedPayload.version must be non-empty")
171
+ if not self.failure_reason:
172
+ raise ValueError("TemplateValidationFailedPayload.failure_reason must be non-empty")
173
+ if self.failure_type is not None and self.failure_type not in _VALID_FAILURE_TYPES:
174
+ raise ValueError(
175
+ f"TemplateValidationFailedPayload.failure_type must be one of {sorted(_VALID_FAILURE_TYPES)}" # noqa: E501
176
+ )
177
+
178
+ def to_dict(self) -> dict[str, Any]:
179
+ """Serialise the payload to a plain ``dict``."""
180
+ d: dict[str, Any] = {
181
+ "template_id": self.template_id,
182
+ "version": self.version,
183
+ "failure_reason": self.failure_reason,
184
+ }
185
+ if self.failure_type is not None:
186
+ d["failure_type"] = self.failure_type
187
+ return d
188
+
189
+ @classmethod
190
+ def from_dict(cls, data: dict[str, Any]) -> TemplateValidationFailedPayload:
191
+ """Deserialise from a plain ``dict``."""
192
+ return cls(
193
+ template_id=data["template_id"],
194
+ version=data["version"],
195
+ failure_reason=data["failure_reason"],
196
+ failure_type=data.get("failure_type"),
197
+ )