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
@@ -0,0 +1,105 @@
1
+ """spanforge.namespaces.guard — Guard payload types (RFC-0001).
2
+
3
+ A single ``GuardPayload`` class is used for all four guard event types.
4
+
5
+ Classes
6
+ -------
7
+ GuardPayload llm.guard.input.blocked, llm.guard.input.passed,
8
+ llm.guard.output.blocked, llm.guard.output.passed
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ from dataclasses import dataclass, field
14
+ from typing import Any
15
+
16
+ __all__ = ["GuardPayload"]
17
+
18
+ _VALID_DIRECTIONS = frozenset({"input", "output"})
19
+ _VALID_ACTIONS = frozenset({"blocked", "passed", "flagged", "modified", "escalated"})
20
+
21
+
22
+ @dataclass
23
+ class GuardPayload:
24
+ """RFC-0001 — Result of a guard classifier applied to LLM input or output.
25
+
26
+ Used with all four guard event types:
27
+ ``llm.guard.input.blocked``, ``llm.guard.input.passed``,
28
+ ``llm.guard.output.blocked``, ``llm.guard.output.passed``.
29
+
30
+ ``content_hash`` stores a SHA-256 hash of the content that was classified.
31
+ Raw content MUST NOT be stored.
32
+ """
33
+
34
+ classifier: str
35
+ direction: str # "input" | "output"
36
+ action: str # "blocked"|"passed"|"flagged"|"modified"|"escalated"
37
+ score: float
38
+ score_min: float | None = None
39
+ score_max: float | None = None
40
+ threshold: float | None = None
41
+ categories: list[str] = field(default_factory=list)
42
+ triggered_categories: list[str] = field(default_factory=list)
43
+ span_id: str | None = None
44
+ latency_ms: float | None = None
45
+ policy_id: str | None = None
46
+ content_hash: str | None = None # 64 lowercase hex chars, SHA-256
47
+
48
+ def __post_init__(self) -> None:
49
+ if not isinstance(self.classifier, str) or not self.classifier:
50
+ raise ValueError("GuardPayload.classifier must be non-empty")
51
+ if self.direction not in _VALID_DIRECTIONS:
52
+ raise ValueError(f"GuardPayload.direction must be one of {sorted(_VALID_DIRECTIONS)}")
53
+ if self.action not in _VALID_ACTIONS:
54
+ raise ValueError(f"GuardPayload.action must be one of {sorted(_VALID_ACTIONS)}")
55
+ if not isinstance(self.score, (int, float)):
56
+ raise ValueError("GuardPayload.score must be a number")
57
+ if self.latency_ms is not None and self.latency_ms < 0:
58
+ raise ValueError("GuardPayload.latency_ms must be non-negative")
59
+
60
+ def to_dict(self) -> dict[str, Any]:
61
+ """Serialise the payload to a plain ``dict``."""
62
+ d: dict[str, Any] = {
63
+ "classifier": self.classifier,
64
+ "direction": self.direction,
65
+ "action": self.action,
66
+ "score": self.score,
67
+ }
68
+ if self.score_min is not None:
69
+ d["score_min"] = self.score_min
70
+ if self.score_max is not None:
71
+ d["score_max"] = self.score_max
72
+ if self.threshold is not None:
73
+ d["threshold"] = self.threshold
74
+ if self.categories:
75
+ d["categories"] = list(self.categories)
76
+ if self.triggered_categories:
77
+ d["triggered_categories"] = list(self.triggered_categories)
78
+ if self.span_id is not None:
79
+ d["span_id"] = self.span_id
80
+ if self.latency_ms is not None:
81
+ d["latency_ms"] = self.latency_ms
82
+ if self.policy_id is not None:
83
+ d["policy_id"] = self.policy_id
84
+ if self.content_hash is not None:
85
+ d["content_hash"] = self.content_hash
86
+ return d
87
+
88
+ @classmethod
89
+ def from_dict(cls, data: dict[str, Any]) -> GuardPayload:
90
+ """Deserialise from a plain ``dict``."""
91
+ return cls(
92
+ classifier=data["classifier"],
93
+ direction=data["direction"],
94
+ action=data["action"],
95
+ score=float(data["score"]),
96
+ score_min=float(data["score_min"]) if "score_min" in data else None,
97
+ score_max=float(data["score_max"]) if "score_max" in data else None,
98
+ threshold=float(data["threshold"]) if "threshold" in data else None,
99
+ categories=list(data.get("categories", [])),
100
+ triggered_categories=list(data.get("triggered_categories", [])),
101
+ span_id=data.get("span_id"),
102
+ latency_ms=float(data["latency_ms"]) if "latency_ms" in data else None,
103
+ policy_id=data.get("policy_id"),
104
+ content_hash=data.get("content_hash"),
105
+ )
@@ -0,0 +1,91 @@
1
+ """spanforge.namespaces.hitl — Human-in-the-Loop namespace payload types (RFC-0001 SPANFORGE).
2
+
3
+ Classes
4
+ -------
5
+ HITLPayload hitl.queued / hitl.reviewed / hitl.escalated / hitl.timeout
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from dataclasses import dataclass
11
+ from typing import Any, Literal
12
+
13
+ __all__ = ["HITLPayload"]
14
+
15
+ _VALID_STATUSES = frozenset({"queued", "approved", "rejected", "escalated", "timeout"})
16
+ _VALID_RISK_TIERS = frozenset({"low", "medium", "high", "critical"})
17
+
18
+
19
+ @dataclass
20
+ class HITLPayload:
21
+ """RFC-0001 SPANFORGE — payload for hitl.* events.
22
+
23
+ Captures human-in-the-loop review decisions for EU AI Act mandatory
24
+ human oversight on high-risk AI systems (R — Responsibility).
25
+ """
26
+
27
+ decision_id: str
28
+ agent_id: str
29
+ risk_tier: Literal["low", "medium", "high", "critical"]
30
+ status: Literal["queued", "approved", "rejected", "escalated", "timeout"]
31
+ reason: str
32
+ reviewer: str | None = None
33
+ sla_seconds: int = 3600
34
+ queued_at: str | None = None # ISO 8601
35
+ resolved_at: str | None = None # ISO 8601
36
+ escalation_tier: int = 0
37
+ confidence: float | None = None
38
+
39
+ def __post_init__(self) -> None:
40
+ if not self.decision_id:
41
+ raise ValueError("HITLPayload.decision_id must be non-empty")
42
+ if not self.agent_id:
43
+ raise ValueError("HITLPayload.agent_id must be non-empty")
44
+ if self.risk_tier not in _VALID_RISK_TIERS:
45
+ raise ValueError(f"HITLPayload.risk_tier must be one of {sorted(_VALID_RISK_TIERS)}")
46
+ if self.status not in _VALID_STATUSES:
47
+ raise ValueError(f"HITLPayload.status must be one of {sorted(_VALID_STATUSES)}")
48
+ if not self.reason:
49
+ raise ValueError("HITLPayload.reason must be non-empty")
50
+ if self.sla_seconds <= 0:
51
+ raise ValueError("HITLPayload.sla_seconds must be > 0")
52
+ if self.confidence is not None and not (0.0 <= self.confidence <= 1.0):
53
+ raise ValueError("HITLPayload.confidence must be in [0.0, 1.0]")
54
+
55
+ def to_dict(self) -> dict[str, Any]:
56
+ """Serialise to a plain dict."""
57
+ d: dict[str, Any] = {
58
+ "decision_id": self.decision_id,
59
+ "agent_id": self.agent_id,
60
+ "risk_tier": self.risk_tier,
61
+ "status": self.status,
62
+ "reason": self.reason,
63
+ "sla_seconds": self.sla_seconds,
64
+ "escalation_tier": self.escalation_tier,
65
+ }
66
+ if self.reviewer is not None:
67
+ d["reviewer"] = self.reviewer
68
+ if self.queued_at is not None:
69
+ d["queued_at"] = self.queued_at
70
+ if self.resolved_at is not None:
71
+ d["resolved_at"] = self.resolved_at
72
+ if self.confidence is not None:
73
+ d["confidence"] = self.confidence
74
+ return d
75
+
76
+ @classmethod
77
+ def from_dict(cls, data: dict[str, Any]) -> HITLPayload:
78
+ """Deserialise from a plain dict."""
79
+ return cls(
80
+ decision_id=data["decision_id"],
81
+ agent_id=data["agent_id"],
82
+ risk_tier=data["risk_tier"],
83
+ status=data["status"],
84
+ reason=data["reason"],
85
+ reviewer=data.get("reviewer"),
86
+ sla_seconds=int(data.get("sla_seconds", 3600)),
87
+ queued_at=data.get("queued_at"),
88
+ resolved_at=data.get("resolved_at"),
89
+ escalation_tier=int(data.get("escalation_tier", 0)),
90
+ confidence=data.get("confidence"),
91
+ )
@@ -0,0 +1,72 @@
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
+
8
+ from __future__ import annotations
9
+
10
+ from dataclasses import dataclass
11
+ from typing import Any
12
+
13
+ __all__ = ["LatencyPayload"]
14
+
15
+
16
+ @dataclass
17
+ class LatencyPayload:
18
+ """RFC-0001 SPANFORGE \u2014 payload for latency.* events.
19
+
20
+ Captures end-to-end response time, per-step breakdown, and SLA compliance
21
+ tracking (T \u2014 Traceability / S \u2014 Safety Guardrails).
22
+ """
23
+
24
+ agent_id: str
25
+ operation: str
26
+ latency_ms: float
27
+ sla_target_ms: float
28
+ sla_met: bool
29
+ p50_ms: float | None = None
30
+ p95_ms: float | None = None
31
+ p99_ms: float | None = None
32
+
33
+ def __post_init__(self) -> None:
34
+ if not self.agent_id:
35
+ raise ValueError("LatencyPayload.agent_id must be non-empty")
36
+ if not self.operation:
37
+ raise ValueError("LatencyPayload.operation must be non-empty")
38
+ if self.latency_ms < 0:
39
+ raise ValueError("LatencyPayload.latency_ms must be >= 0")
40
+ if self.sla_target_ms <= 0:
41
+ raise ValueError("LatencyPayload.sla_target_ms must be > 0")
42
+
43
+ def to_dict(self) -> dict[str, Any]:
44
+ """Serialise to a plain dict."""
45
+ d: dict[str, Any] = {
46
+ "agent_id": self.agent_id,
47
+ "operation": self.operation,
48
+ "latency_ms": self.latency_ms,
49
+ "sla_target_ms": self.sla_target_ms,
50
+ "sla_met": self.sla_met,
51
+ }
52
+ if self.p50_ms is not None:
53
+ d["p50_ms"] = self.p50_ms
54
+ if self.p95_ms is not None:
55
+ d["p95_ms"] = self.p95_ms
56
+ if self.p99_ms is not None:
57
+ d["p99_ms"] = self.p99_ms
58
+ return d
59
+
60
+ @classmethod
61
+ def from_dict(cls, data: dict[str, Any]) -> LatencyPayload:
62
+ """Deserialise from a plain dict."""
63
+ return cls(
64
+ agent_id=data["agent_id"],
65
+ operation=data["operation"],
66
+ latency_ms=float(data["latency_ms"]),
67
+ sla_target_ms=float(data["sla_target_ms"]),
68
+ sla_met=bool(data["sla_met"]),
69
+ p50_ms=data.get("p50_ms"),
70
+ p95_ms=data.get("p95_ms"),
71
+ p99_ms=data.get("p99_ms"),
72
+ )
@@ -0,0 +1,190 @@
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
+
10
+ from __future__ import annotations
11
+
12
+ from dataclasses import dataclass, field
13
+ from typing import Any
14
+
15
+ __all__ = [
16
+ "PromptRenderedPayload",
17
+ "PromptTemplateLoadedPayload",
18
+ "PromptVersionChangedPayload",
19
+ ]
20
+
21
+ _VALID_SOURCES = frozenset({"registry", "file", "database", "remote_url", "inline"})
22
+ _SHA256_HEX_LEN = 64 # SHA-256 hex digest length (characters)
23
+
24
+
25
+ @dataclass
26
+ class PromptRenderedPayload:
27
+ """RFC-0001 — A prompt template was rendered with variables.
28
+
29
+ ``rendered_hash`` is the SHA-256 of the fully-rendered prompt text.
30
+ The rendered text MUST NOT be stored.
31
+ """
32
+
33
+ template_id: str
34
+ version: str
35
+ rendered_hash: str # 64 lowercase hex chars, SHA-256 of rendered text
36
+ variable_count: int | None = None
37
+ variable_names: list[str] = field(default_factory=list)
38
+ char_count: int | None = None
39
+ token_estimate: int | None = None
40
+ language: str | None = None
41
+ span_id: str | None = None
42
+
43
+ def __post_init__(self) -> None:
44
+ if not self.template_id:
45
+ raise ValueError("PromptRenderedPayload.template_id must be non-empty")
46
+ if not self.version:
47
+ raise ValueError("PromptRenderedPayload.version must be non-empty")
48
+ if not self.rendered_hash or len(self.rendered_hash) != _SHA256_HEX_LEN:
49
+ raise ValueError("PromptRenderedPayload.rendered_hash must be 64 hex chars (SHA-256)")
50
+
51
+ def to_dict(self) -> dict[str, Any]:
52
+ """Serialise the payload to a plain ``dict``."""
53
+ d: dict[str, Any] = {
54
+ "template_id": self.template_id,
55
+ "version": self.version,
56
+ "rendered_hash": self.rendered_hash,
57
+ }
58
+ if self.variable_count is not None:
59
+ d["variable_count"] = self.variable_count
60
+ if self.variable_names:
61
+ d["variable_names"] = list(self.variable_names)
62
+ if self.char_count is not None:
63
+ d["char_count"] = self.char_count
64
+ if self.token_estimate is not None:
65
+ d["token_estimate"] = self.token_estimate
66
+ if self.language is not None:
67
+ d["language"] = self.language
68
+ if self.span_id is not None:
69
+ d["span_id"] = self.span_id
70
+ return d
71
+
72
+ @classmethod
73
+ def from_dict(cls, data: dict[str, Any]) -> PromptRenderedPayload:
74
+ """Deserialise from a plain ``dict``."""
75
+ return cls(
76
+ template_id=data["template_id"],
77
+ version=data["version"],
78
+ rendered_hash=data["rendered_hash"],
79
+ variable_count=int(data["variable_count"]) if "variable_count" in data else None,
80
+ variable_names=list(data.get("variable_names", [])),
81
+ char_count=int(data["char_count"]) if "char_count" in data else None,
82
+ token_estimate=int(data["token_estimate"]) if "token_estimate" in data else None,
83
+ language=data.get("language"),
84
+ span_id=data.get("span_id"),
85
+ )
86
+
87
+
88
+ @dataclass
89
+ class PromptTemplateLoadedPayload:
90
+ """RFC-0001 — A prompt template was loaded from a source."""
91
+
92
+ template_id: str
93
+ version: str
94
+ source: str # "registry"|"file"|"database"|"remote_url"|"inline"
95
+ template_hash: str | None = None # 64 hex chars
96
+ load_duration_ms: float | None = None
97
+ cache_hit: bool | None = None
98
+
99
+ def __post_init__(self) -> None:
100
+ if not self.template_id:
101
+ raise ValueError("PromptTemplateLoadedPayload.template_id must be non-empty")
102
+ if not self.version:
103
+ raise ValueError("PromptTemplateLoadedPayload.version must be non-empty")
104
+ if self.source not in _VALID_SOURCES:
105
+ raise ValueError(
106
+ f"PromptTemplateLoadedPayload.source must be one of {sorted(_VALID_SOURCES)}"
107
+ )
108
+ if self.template_hash is not None and len(self.template_hash) != _SHA256_HEX_LEN:
109
+ raise ValueError("PromptTemplateLoadedPayload.template_hash must be 64 hex chars")
110
+
111
+ def to_dict(self) -> dict[str, Any]:
112
+ """Serialise the payload to a plain ``dict``."""
113
+ d: dict[str, Any] = {
114
+ "template_id": self.template_id,
115
+ "version": self.version,
116
+ "source": self.source,
117
+ }
118
+ if self.template_hash is not None:
119
+ d["template_hash"] = self.template_hash
120
+ if self.load_duration_ms is not None:
121
+ d["load_duration_ms"] = self.load_duration_ms
122
+ if self.cache_hit is not None:
123
+ d["cache_hit"] = self.cache_hit
124
+ return d
125
+
126
+ @classmethod
127
+ def from_dict(cls, data: dict[str, Any]) -> PromptTemplateLoadedPayload:
128
+ """Deserialise from a plain ``dict``."""
129
+ return cls(
130
+ template_id=data["template_id"],
131
+ version=data["version"],
132
+ source=data["source"],
133
+ template_hash=data.get("template_hash"),
134
+ load_duration_ms=float(data["load_duration_ms"])
135
+ if "load_duration_ms" in data
136
+ else None,
137
+ cache_hit=bool(data["cache_hit"]) if "cache_hit" in data else None,
138
+ )
139
+
140
+
141
+ @dataclass
142
+ class PromptVersionChangedPayload:
143
+ """RFC-0001 — A prompt template was promoted to a new version."""
144
+
145
+ template_id: str
146
+ previous_version: str
147
+ new_version: str
148
+ change_reason: str
149
+ changed_by: str | None = None
150
+ previous_hash: str | None = None # 64 hex chars
151
+ new_hash: str | None = None # 64 hex chars
152
+
153
+ def __post_init__(self) -> None:
154
+ if not self.template_id:
155
+ raise ValueError("PromptVersionChangedPayload.template_id must be non-empty")
156
+ if not self.previous_version:
157
+ raise ValueError("PromptVersionChangedPayload.previous_version must be non-empty")
158
+ if not self.new_version:
159
+ raise ValueError("PromptVersionChangedPayload.new_version must be non-empty")
160
+ if not self.change_reason:
161
+ raise ValueError("PromptVersionChangedPayload.change_reason must be non-empty")
162
+
163
+ def to_dict(self) -> dict[str, Any]:
164
+ """Serialise the payload to a plain ``dict``."""
165
+ d: dict[str, Any] = {
166
+ "template_id": self.template_id,
167
+ "previous_version": self.previous_version,
168
+ "new_version": self.new_version,
169
+ "change_reason": self.change_reason,
170
+ }
171
+ if self.changed_by is not None:
172
+ d["changed_by"] = self.changed_by
173
+ if self.previous_hash is not None:
174
+ d["previous_hash"] = self.previous_hash
175
+ if self.new_hash is not None:
176
+ d["new_hash"] = self.new_hash
177
+ return d
178
+
179
+ @classmethod
180
+ def from_dict(cls, data: dict[str, Any]) -> PromptVersionChangedPayload:
181
+ """Deserialise from a plain ``dict``."""
182
+ return cls(
183
+ template_id=data["template_id"],
184
+ previous_version=data["previous_version"],
185
+ new_version=data["new_version"],
186
+ change_reason=data["change_reason"],
187
+ changed_by=data.get("changed_by"),
188
+ previous_hash=data.get("previous_hash"),
189
+ new_hash=data.get("new_hash"),
190
+ )
@@ -0,0 +1,173 @@
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
+
10
+ from __future__ import annotations
11
+
12
+ from dataclasses import dataclass, field
13
+ from typing import Any
14
+
15
+ __all__ = [
16
+ "RedactAppliedPayload",
17
+ "RedactPhiDetectedPayload",
18
+ "RedactPiiDetectedPayload",
19
+ ]
20
+
21
+ _VALID_SENSITIVITY_LEVELS = frozenset({"LOW", "MEDIUM", "HIGH", "PII", "PHI"})
22
+
23
+
24
+ @dataclass
25
+ class RedactPiiDetectedPayload:
26
+ """RFC-0001 — PII was detected in an LLM input or output field."""
27
+
28
+ detected_categories: list[str] # minItems=1 — e.g. ["email", "phone"]
29
+ field_names: list[str] # minItems=1 — field paths where PII found
30
+ sensitivity_level: str # "LOW"|"MEDIUM"|"HIGH"|"PII"|"PHI"
31
+ detection_count: int | None = None
32
+ detector: str | None = None
33
+ subject_event_id: str | None = None
34
+
35
+ def __post_init__(self) -> None:
36
+ if not self.detected_categories:
37
+ raise ValueError("RedactPiiDetectedPayload.detected_categories must be non-empty")
38
+ if not self.field_names:
39
+ raise ValueError("RedactPiiDetectedPayload.field_names must be non-empty")
40
+ if self.sensitivity_level not in _VALID_SENSITIVITY_LEVELS:
41
+ raise ValueError(
42
+ f"RedactPiiDetectedPayload.sensitivity_level must be one of {sorted(_VALID_SENSITIVITY_LEVELS)}"
43
+ )
44
+
45
+ def to_dict(self) -> dict[str, Any]:
46
+ """Serialise the payload to a plain ``dict``."""
47
+ d: dict[str, Any] = {
48
+ "detected_categories": list(self.detected_categories),
49
+ "field_names": list(self.field_names),
50
+ "sensitivity_level": self.sensitivity_level,
51
+ }
52
+ if self.detection_count is not None:
53
+ d["detection_count"] = self.detection_count
54
+ if self.detector is not None:
55
+ d["detector"] = self.detector
56
+ if self.subject_event_id is not None:
57
+ d["subject_event_id"] = self.subject_event_id
58
+ return d
59
+
60
+ @classmethod
61
+ def from_dict(cls, data: dict[str, Any]) -> RedactPiiDetectedPayload:
62
+ """Deserialise from a plain ``dict``."""
63
+ return cls(
64
+ detected_categories=list(data["detected_categories"]),
65
+ field_names=list(data["field_names"]),
66
+ sensitivity_level=data["sensitivity_level"],
67
+ detection_count=int(data["detection_count"]) if "detection_count" in data else None,
68
+ detector=data.get("detector"),
69
+ subject_event_id=data.get("subject_event_id"),
70
+ )
71
+
72
+
73
+ @dataclass
74
+ class RedactPhiDetectedPayload:
75
+ """RFC-0001 — PHI was detected (HIPAA-covered health information).
76
+
77
+ ``sensitivity_level`` MUST always be ``"PHI"`` for this payload type.
78
+ """
79
+
80
+ detected_categories: list[str]
81
+ field_names: list[str]
82
+ sensitivity_level: str = "PHI" # MUST be "PHI"
83
+ detection_count: int | None = None
84
+ detector: str | None = None
85
+ subject_event_id: str | None = None
86
+ hipaa_covered: bool | None = None
87
+
88
+ def __post_init__(self) -> None:
89
+ if not self.detected_categories:
90
+ raise ValueError("RedactPhiDetectedPayload.detected_categories must be non-empty")
91
+ if not self.field_names:
92
+ raise ValueError("RedactPhiDetectedPayload.field_names must be non-empty")
93
+ if self.sensitivity_level != "PHI":
94
+ raise ValueError("RedactPhiDetectedPayload.sensitivity_level MUST be 'PHI'")
95
+
96
+ def to_dict(self) -> dict[str, Any]:
97
+ """Serialise the payload to a plain ``dict``."""
98
+ d: dict[str, Any] = {
99
+ "detected_categories": list(self.detected_categories),
100
+ "field_names": list(self.field_names),
101
+ "sensitivity_level": self.sensitivity_level,
102
+ }
103
+ if self.detection_count is not None:
104
+ d["detection_count"] = self.detection_count
105
+ if self.detector is not None:
106
+ d["detector"] = self.detector
107
+ if self.subject_event_id is not None:
108
+ d["subject_event_id"] = self.subject_event_id
109
+ if self.hipaa_covered is not None:
110
+ d["hipaa_covered"] = self.hipaa_covered
111
+ return d
112
+
113
+ @classmethod
114
+ def from_dict(cls, data: dict[str, Any]) -> RedactPhiDetectedPayload:
115
+ """Deserialise from a plain ``dict``."""
116
+ return cls(
117
+ detected_categories=list(data["detected_categories"]),
118
+ field_names=list(data["field_names"]),
119
+ sensitivity_level=data.get("sensitivity_level", "PHI"),
120
+ detection_count=int(data["detection_count"]) if "detection_count" in data else None,
121
+ detector=data.get("detector"),
122
+ subject_event_id=data.get("subject_event_id"),
123
+ hipaa_covered=bool(data["hipaa_covered"]) if "hipaa_covered" in data else None,
124
+ )
125
+
126
+
127
+ @dataclass
128
+ class RedactAppliedPayload:
129
+ """RFC-0001 — A redaction policy was applied to one or more fields."""
130
+
131
+ policy_min_sensitivity: str # "LOW"|"MEDIUM"|"HIGH"|"PII"|"PHI"
132
+ redacted_by: str
133
+ redacted_count: int
134
+ redacted_field_names: list[str] = field(default_factory=list)
135
+ subject_event_id: str | None = None
136
+ verified: bool | None = None
137
+
138
+ def __post_init__(self) -> None:
139
+ if self.policy_min_sensitivity not in _VALID_SENSITIVITY_LEVELS:
140
+ raise ValueError(
141
+ f"RedactAppliedPayload.policy_min_sensitivity must be one of {sorted(_VALID_SENSITIVITY_LEVELS)}"
142
+ )
143
+ if not isinstance(self.redacted_by, str) or not self.redacted_by:
144
+ raise ValueError("RedactAppliedPayload.redacted_by must be non-empty")
145
+ if not isinstance(self.redacted_count, int) or self.redacted_count < 0:
146
+ raise ValueError("RedactAppliedPayload.redacted_count must be a non-negative int")
147
+
148
+ def to_dict(self) -> dict[str, Any]:
149
+ """Serialise the payload to a plain ``dict``."""
150
+ d: dict[str, Any] = {
151
+ "policy_min_sensitivity": self.policy_min_sensitivity,
152
+ "redacted_by": self.redacted_by,
153
+ "redacted_count": self.redacted_count,
154
+ }
155
+ if self.redacted_field_names:
156
+ d["redacted_field_names"] = list(self.redacted_field_names)
157
+ if self.subject_event_id is not None:
158
+ d["subject_event_id"] = self.subject_event_id
159
+ if self.verified is not None:
160
+ d["verified"] = self.verified
161
+ return d
162
+
163
+ @classmethod
164
+ def from_dict(cls, data: dict[str, Any]) -> RedactAppliedPayload:
165
+ """Deserialise from a plain ``dict``."""
166
+ return cls(
167
+ policy_min_sensitivity=data["policy_min_sensitivity"],
168
+ redacted_by=data["redacted_by"],
169
+ redacted_count=int(data["redacted_count"]),
170
+ redacted_field_names=list(data.get("redacted_field_names", [])),
171
+ subject_event_id=data.get("subject_event_id"),
172
+ verified=bool(data["verified"]) if "verified" in data else None,
173
+ )