traceforge-toolkit 0.1.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 (205) hide show
  1. traceforge/__init__.py +72 -0
  2. traceforge/__main__.py +5 -0
  3. traceforge/_generated.py +254 -0
  4. traceforge/adapters/__init__.py +12 -0
  5. traceforge/adapters/base.py +82 -0
  6. traceforge/adapters/genai_otel.py +164 -0
  7. traceforge/adapters/mapped_json.py +297 -0
  8. traceforge/adapters/otel.py +220 -0
  9. traceforge/boundary/__init__.py +46 -0
  10. traceforge/boundary/data/boundary-model.joblib +0 -0
  11. traceforge/boundary/decode.py +87 -0
  12. traceforge/boundary/features.py +137 -0
  13. traceforge/boundary/inference.py +267 -0
  14. traceforge/boundary/inferencer.py +146 -0
  15. traceforge/classify/__init__.py +95 -0
  16. traceforge/classify/cmd.py +109 -0
  17. traceforge/classify/coding.py +213 -0
  18. traceforge/classify/config.py +554 -0
  19. traceforge/classify/core.py +266 -0
  20. traceforge/classify/data/binary_info.yaml +358 -0
  21. traceforge/classify/data/canonical_tools.yaml +251 -0
  22. traceforge/classify/data/effect_overrides.yaml +480 -0
  23. traceforge/classify/data/mcp_profiles.yaml +711 -0
  24. traceforge/classify/data/recommendation_rules.yaml +111 -0
  25. traceforge/classify/data/risk.yaml +534 -0
  26. traceforge/classify/data/shell_defaults.yaml +95 -0
  27. traceforge/classify/data/shell_rules.yaml +1192 -0
  28. traceforge/classify/data/tool_classifications.yaml +215 -0
  29. traceforge/classify/data/verb_inference.yaml +218 -0
  30. traceforge/classify/mcp.py +181 -0
  31. traceforge/classify/phases.py +75 -0
  32. traceforge/classify/powershell.py +93 -0
  33. traceforge/classify/registry.py +138 -0
  34. traceforge/classify/risk.py +553 -0
  35. traceforge/classify/rules.py +215 -0
  36. traceforge/classify/schema.yaml +282 -0
  37. traceforge/classify/shell.py +503 -0
  38. traceforge/classify/tools.py +91 -0
  39. traceforge/classify/workflow.py +32 -0
  40. traceforge/cli/__init__.py +42 -0
  41. traceforge/cli/config_cmd.py +130 -0
  42. traceforge/cli/detect.py +46 -0
  43. traceforge/cli/download_cmd.py +74 -0
  44. traceforge/cli/factory.py +60 -0
  45. traceforge/cli/gate_cmd.py +30 -0
  46. traceforge/cli/init_cmd.py +86 -0
  47. traceforge/cli/replay.py +130 -0
  48. traceforge/cli/runner.py +181 -0
  49. traceforge/cli/score.py +217 -0
  50. traceforge/cli/status.py +65 -0
  51. traceforge/cli/watch.py +297 -0
  52. traceforge/config/__init__.py +80 -0
  53. traceforge/config/defaults.py +149 -0
  54. traceforge/config/loader.py +239 -0
  55. traceforge/config/mappings.py +104 -0
  56. traceforge/config/models.py +579 -0
  57. traceforge/enricher.py +576 -0
  58. traceforge/formatting/__init__.py +12 -0
  59. traceforge/formatting/budget.py +92 -0
  60. traceforge/formatting/density.py +154 -0
  61. traceforge/gate/__init__.py +17 -0
  62. traceforge/gate/client.py +145 -0
  63. traceforge/gate/external.py +305 -0
  64. traceforge/gate/registry.py +108 -0
  65. traceforge/gate/server.py +174 -0
  66. traceforge/gates/__init__.py +8 -0
  67. traceforge/gates/pii.py +352 -0
  68. traceforge/gates/pii_patterns.yaml +228 -0
  69. traceforge/governance/__init__.py +121 -0
  70. traceforge/governance/assessor.py +441 -0
  71. traceforge/governance/budget.py +59 -0
  72. traceforge/governance/canonical.py +56 -0
  73. traceforge/governance/codec.py +414 -0
  74. traceforge/governance/context.py +249 -0
  75. traceforge/governance/drift.py +196 -0
  76. traceforge/governance/emitter.py +234 -0
  77. traceforge/governance/envelope.py +138 -0
  78. traceforge/governance/ifc.py +364 -0
  79. traceforge/governance/integrity.py +162 -0
  80. traceforge/governance/labeler.py +250 -0
  81. traceforge/governance/mcp_drift.py +328 -0
  82. traceforge/governance/monitor.py +539 -0
  83. traceforge/governance/observer.py +276 -0
  84. traceforge/governance/persistence.py +401 -0
  85. traceforge/governance/phase1.py +77 -0
  86. traceforge/governance/pii.py +276 -0
  87. traceforge/governance/pipeline.py +840 -0
  88. traceforge/governance/registry.py +110 -0
  89. traceforge/governance/results.py +183 -0
  90. traceforge/governance/risk_wrapper.py +113 -0
  91. traceforge/governance/rules.py +368 -0
  92. traceforge/governance/scorer.py +262 -0
  93. traceforge/governance/shield.py +318 -0
  94. traceforge/governance/state.py +466 -0
  95. traceforge/governance/types.py +176 -0
  96. traceforge/mappings/__init__.py +1 -0
  97. traceforge/mappings/aider.yaml +216 -0
  98. traceforge/mappings/aider_markdown.yaml +113 -0
  99. traceforge/mappings/amazonq.yaml +56 -0
  100. traceforge/mappings/antigravity.yaml +88 -0
  101. traceforge/mappings/claude.yaml +93 -0
  102. traceforge/mappings/cline.yaml +286 -0
  103. traceforge/mappings/codex.yaml +158 -0
  104. traceforge/mappings/continue_dev.yaml +57 -0
  105. traceforge/mappings/copilot.yaml +266 -0
  106. traceforge/mappings/copilot_markdown.yaml +72 -0
  107. traceforge/mappings/copilot_vscode.yaml +181 -0
  108. traceforge/mappings/crewai.yaml +592 -0
  109. traceforge/mappings/goose.yaml +128 -0
  110. traceforge/mappings/langgraph.yaml +165 -0
  111. traceforge/mappings/maf.yaml +90 -0
  112. traceforge/mappings/maf_transcript.yaml +99 -0
  113. traceforge/mappings/openai_agents.yaml +144 -0
  114. traceforge/mappings/opencode.yaml +473 -0
  115. traceforge/mappings/openhands.yaml +320 -0
  116. traceforge/mappings/pydantic_ai.yaml +183 -0
  117. traceforge/mappings/smolagents.yaml +89 -0
  118. traceforge/mappings/sweagent.yaml +82 -0
  119. traceforge/migrations/__init__.py +1 -0
  120. traceforge/migrations/env.py +60 -0
  121. traceforge/migrations/models.py +145 -0
  122. traceforge/migrations/runner.py +44 -0
  123. traceforge/migrations/script.py.mako +25 -0
  124. traceforge/migrations/versions/0001_initial.py +176 -0
  125. traceforge/migrations/versions/__init__.py +4 -0
  126. traceforge/models.py +29 -0
  127. traceforge/parsers/__init__.py +21 -0
  128. traceforge/parsers/aider.py +416 -0
  129. traceforge/parsers/base.py +226 -0
  130. traceforge/parsers/copilot.py +314 -0
  131. traceforge/phase/__init__.py +50 -0
  132. traceforge/phase/data/phase-model.joblib +0 -0
  133. traceforge/phase/data/potion-base-8M/README.md +99 -0
  134. traceforge/phase/data/potion-base-8M/config.json +13 -0
  135. traceforge/phase/data/potion-base-8M/model.safetensors +0 -0
  136. traceforge/phase/data/potion-base-8M/modules.json +14 -0
  137. traceforge/phase/data/potion-base-8M/tokenizer.json +1 -0
  138. traceforge/phase/event_rows.py +107 -0
  139. traceforge/phase/features.py +468 -0
  140. traceforge/phase/inference.py +279 -0
  141. traceforge/phase/inferencer.py +171 -0
  142. traceforge/phase/segmentation.py +258 -0
  143. traceforge/pipeline.py +891 -0
  144. traceforge/preprocessors/__init__.py +34 -0
  145. traceforge/preprocessors/amazonq.py +224 -0
  146. traceforge/preprocessors/antigravity.py +116 -0
  147. traceforge/preprocessors/claude.py +95 -0
  148. traceforge/preprocessors/cline.py +36 -0
  149. traceforge/preprocessors/codex.py +311 -0
  150. traceforge/preprocessors/continue_dev.py +119 -0
  151. traceforge/preprocessors/copilot_vscode.py +171 -0
  152. traceforge/preprocessors/goose.py +156 -0
  153. traceforge/preprocessors/maf_transcript.py +84 -0
  154. traceforge/preprocessors/openai_agents.py +86 -0
  155. traceforge/preprocessors/opencode.py +85 -0
  156. traceforge/preprocessors/openhands.py +36 -0
  157. traceforge/preprocessors/pydantic_ai.py +62 -0
  158. traceforge/preprocessors/registry.py +24 -0
  159. traceforge/preprocessors/smolagents.py +90 -0
  160. traceforge/py.typed +0 -0
  161. traceforge/sdk/__init__.py +59 -0
  162. traceforge/sdk/gate_policy.py +63 -0
  163. traceforge/sdk/gate_types.py +140 -0
  164. traceforge/sdk/pipeline.py +265 -0
  165. traceforge/sdk/verdict.py +81 -0
  166. traceforge/sinks/__init__.py +23 -0
  167. traceforge/sinks/base.py +95 -0
  168. traceforge/sinks/callback.py +132 -0
  169. traceforge/sinks/console.py +112 -0
  170. traceforge/sinks/factory.py +94 -0
  171. traceforge/sinks/jsonl.py +125 -0
  172. traceforge/sinks/otel_exporter.py +212 -0
  173. traceforge/sinks/parquet.py +260 -0
  174. traceforge/sinks/s3.py +206 -0
  175. traceforge/sinks/sqlite_output.py +234 -0
  176. traceforge/sinks/webhook.py +136 -0
  177. traceforge/sources/__init__.py +18 -0
  178. traceforge/sources/auto_detect.py +173 -0
  179. traceforge/sources/base.py +45 -0
  180. traceforge/sources/file_poll.py +136 -0
  181. traceforge/sources/file_watch.py +221 -0
  182. traceforge/sources/http_poll.py +141 -0
  183. traceforge/sources/replay.py +63 -0
  184. traceforge/sources/sqlite.py +187 -0
  185. traceforge/sources/sse.py +198 -0
  186. traceforge/telemetry/__init__.py +192 -0
  187. traceforge/title/__init__.py +23 -0
  188. traceforge/title/_resolve.py +79 -0
  189. traceforge/title/context.py +295 -0
  190. traceforge/title/data/boilerplate_files.json +14 -0
  191. traceforge/title/heuristics.py +429 -0
  192. traceforge/title/hygiene.py +90 -0
  193. traceforge/title/inference.py +314 -0
  194. traceforge/title/inferencer.py +477 -0
  195. traceforge/title/naming.py +398 -0
  196. traceforge/trace.py +291 -0
  197. traceforge/tracking/__init__.py +30 -0
  198. traceforge/tracking/models.py +115 -0
  199. traceforge/tracking/phase_tracker.py +288 -0
  200. traceforge/types.py +315 -0
  201. traceforge_toolkit-0.1.0.dist-info/METADATA +188 -0
  202. traceforge_toolkit-0.1.0.dist-info/RECORD +205 -0
  203. traceforge_toolkit-0.1.0.dist-info/WHEEL +4 -0
  204. traceforge_toolkit-0.1.0.dist-info/entry_points.txt +2 -0
  205. traceforge_toolkit-0.1.0.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,196 @@
1
+ """Phase-aware behavioral drift detection with transition-specific bonuses."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import math
6
+ from dataclasses import dataclass
7
+ from typing import TYPE_CHECKING
8
+
9
+ if TYPE_CHECKING:
10
+ from traceforge.governance.persistence import SystemStore
11
+ from traceforge.governance.state import SessionStateSnapshot
12
+ from traceforge.governance.types import EnrichmentContext
13
+
14
+
15
+ # Suspicious transition bonuses (from → to: bonus)
16
+ _TRANSITION_BONUSES: dict[tuple[str, str], int] = {
17
+ ("testing", "implementation"): 10, # Verification → destructive implementation
18
+ ("verification", "implementation"): 10,
19
+ ("testing", "destructive"): 18, # Verification → destructive
20
+ ("verification", "destructive"): 18,
21
+ ("exploration", "network"): 15, # Exploration → network write
22
+ ("exploration", "deployment"): 12, # Exploration → deployment (skipping impl)
23
+ }
24
+
25
+ # Oscillation threshold
26
+ _OSCILLATION_THRESHOLD = 5 # >5 transitions in window = +20 bonus
27
+ _OSCILLATION_BONUS = 20
28
+ _WARMUP_EVENTS = 5 # Minimum events before drift detection activates
29
+
30
+
31
+ @dataclass(frozen=True)
32
+ class DriftAssessment:
33
+ """Full drift assessment per the design spec."""
34
+
35
+ phase_window: tuple[str, ...]
36
+ baseline_distribution: tuple[tuple[str, float], ...] # Sorted pairs (immutable)
37
+ current_phase: str
38
+ anomaly_score: float # 0.0–1.0 deviation from baseline
39
+ risk_bonus: int # 0–25 pts added to risk
40
+ transitions: int # Phase transitions in window
41
+ anomaly: bool # True if risk_bonus > 0
42
+
43
+
44
+ class DriftDetector:
45
+ """Stateless drift detector. Phase window from session state, baseline from store."""
46
+
47
+ def __init__(self, store: "SystemStore", window_size: int = 20, threshold: float = 0.3) -> None:
48
+ self._store = store
49
+ self._window_size = window_size
50
+ self._threshold = threshold
51
+
52
+ def detect(
53
+ self,
54
+ ctx: "EnrichmentContext",
55
+ state_snapshot: "SessionStateSnapshot",
56
+ cap: set[str],
57
+ ) -> DriftAssessment | None:
58
+ """Full drift detection: divergence + transition bonuses + oscillation."""
59
+ phase_window = state_snapshot.phase_window
60
+ if len(phase_window) < _WARMUP_EVENTS:
61
+ return None
62
+
63
+ current_phase = phase_window[-1] if phase_window else "unknown"
64
+
65
+ # Count transitions in window
66
+ transitions = self._count_transitions(phase_window)
67
+
68
+ # Compute distribution divergence
69
+ current_dist = self._compute_distribution(phase_window)
70
+ baseline = self._get_baseline(ctx)
71
+
72
+ divergence = 0.0
73
+ baseline_tuples: tuple[tuple[str, float], ...] = ()
74
+
75
+ if baseline:
76
+ baseline_dist = baseline["phase_counts"]
77
+ total = baseline["total_events"]
78
+ if total > 0:
79
+ norm_baseline = {k: v / total for k, v in baseline_dist.items()}
80
+ baseline_tuples = tuple(sorted(norm_baseline.items()))
81
+ divergence = self._js_divergence(current_dist, norm_baseline)
82
+
83
+ # Compute risk bonus from multiple sources
84
+ risk_bonus = 0
85
+
86
+ # 1. Transition-specific bonuses
87
+ if len(phase_window) >= 2:
88
+ prev_phase = phase_window[-2]
89
+ transition_key = (prev_phase, current_phase)
90
+ risk_bonus += _TRANSITION_BONUSES.get(transition_key, 0)
91
+
92
+ # 2. Oscillation bonus
93
+ if transitions > _OSCILLATION_THRESHOLD:
94
+ risk_bonus += _OSCILLATION_BONUS
95
+
96
+ # 3. Divergence-based bonus (scale anomaly_score to 0-15 pts)
97
+ if divergence > self._threshold:
98
+ risk_bonus += min(int(divergence * 25), 15)
99
+
100
+ # Cap total at 25
101
+ risk_bonus = min(risk_bonus, 25)
102
+
103
+ anomaly = risk_bonus > 0
104
+
105
+ if anomaly:
106
+ # phase_anomaly tracked via structure only (not capability)
107
+ # to avoid polluting canonical hash with session-contextual state
108
+ pass
109
+
110
+ return DriftAssessment(
111
+ phase_window=phase_window,
112
+ baseline_distribution=baseline_tuples,
113
+ current_phase=current_phase,
114
+ anomaly_score=divergence,
115
+ risk_bonus=risk_bonus,
116
+ transitions=transitions,
117
+ anomaly=anomaly,
118
+ )
119
+
120
+ def check_drift(
121
+ self,
122
+ phase_window: tuple[str, ...],
123
+ current_phase: str,
124
+ baseline: tuple[tuple[str, float], ...] | None,
125
+ ) -> DriftAssessment | None:
126
+ """Pure function version — receives pre-loaded baseline."""
127
+ if len(phase_window) < _WARMUP_EVENTS:
128
+ return None
129
+
130
+ transitions = self._count_transitions(phase_window)
131
+ current_dist = self._compute_distribution(phase_window)
132
+
133
+ divergence = 0.0
134
+ if baseline:
135
+ norm_baseline = dict(baseline)
136
+ divergence = self._js_divergence(current_dist, norm_baseline)
137
+
138
+ risk_bonus = 0
139
+ if len(phase_window) >= 2:
140
+ prev_phase = phase_window[-2]
141
+ risk_bonus += _TRANSITION_BONUSES.get((prev_phase, current_phase), 0)
142
+ if transitions > _OSCILLATION_THRESHOLD:
143
+ risk_bonus += _OSCILLATION_BONUS
144
+ if divergence > self._threshold:
145
+ risk_bonus += min(int(divergence * 25), 15)
146
+ risk_bonus = min(risk_bonus, 25)
147
+
148
+ return DriftAssessment(
149
+ phase_window=phase_window,
150
+ baseline_distribution=baseline or (),
151
+ current_phase=current_phase,
152
+ anomaly_score=divergence,
153
+ risk_bonus=risk_bonus,
154
+ transitions=transitions,
155
+ anomaly=risk_bonus > 0,
156
+ )
157
+
158
+ def _get_baseline(self, ctx: "EnrichmentContext") -> dict | None:
159
+ # Use pre-loaded baseline from context when available
160
+ if ctx.drift_baseline:
161
+ return {
162
+ "phase_counts": dict(ctx.drift_baseline),
163
+ "total_events": sum(v for _, v in ctx.drift_baseline),
164
+ }
165
+ repo = ctx.project_root or "unknown"
166
+ return self._store.get_drift_baseline("unknown", repo)
167
+
168
+ def _count_transitions(self, phases: tuple[str, ...]) -> int:
169
+ """Count phase transitions (consecutive different phases) in window."""
170
+ count = 0
171
+ for i in range(1, len(phases)):
172
+ if phases[i] != phases[i - 1]:
173
+ count += 1
174
+ return count
175
+
176
+ def _compute_distribution(self, phases: tuple[str, ...] | list[str]) -> dict[str, float]:
177
+ counts: dict[str, int] = {}
178
+ for p in phases:
179
+ counts[p] = counts.get(p, 0) + 1
180
+ total = len(phases)
181
+ return {k: v / total for k, v in counts.items()}
182
+
183
+ def _js_divergence(self, p: dict[str, float], q: dict[str, float]) -> float:
184
+ """Jensen-Shannon divergence (symmetric, bounded [0, ln(2)])."""
185
+ all_keys = set(p.keys()) | set(q.keys())
186
+ eps = 1e-10
187
+ divergence = 0.0
188
+ for k in all_keys:
189
+ pk = p.get(k, eps)
190
+ qk = q.get(k, eps)
191
+ m = (pk + qk) / 2
192
+ if pk > eps:
193
+ divergence += pk * math.log(pk / m)
194
+ if qk > eps:
195
+ divergence += qk * math.log(qk / m)
196
+ return divergence / 2
@@ -0,0 +1,234 @@
1
+ """Async emission actor for governance-enriched events, with backpressure.
2
+
3
+ The observer enriches **synchronously** (single-writer governance: ``observe_event``
4
+ runs exactly once per event and advances the tool-call budget) and hands an
5
+ already-scored ``(event, meta)`` pair to this actor via :meth:`EnrichedEmitter.submit`.
6
+ The actor owns *only* audit emission and backpressure — it never enriches, so it
7
+ can never re-run ``observe_event`` and double-count the budget.
8
+
9
+ A bounded :class:`asyncio.Queue` decouples the latency-sensitive enforcement path
10
+ (the observer hook returns ``SessionMeta`` to the host immediately) from
11
+ potentially slow sinks. When the queue is full the **oldest audit record** is
12
+ dropped — never an enforcement decision, which already took effect synchronously
13
+ in the observer — the drop is counted durably via an injected ``record_drop``
14
+ callback, and a **coalesced** :class:`ContextGapEvent` is emitted downstream so
15
+ consumers see an explicit gap marker instead of silent loss.
16
+ """
17
+
18
+ from __future__ import annotations
19
+
20
+ import asyncio
21
+ import logging
22
+ import uuid
23
+ from datetime import datetime, timezone
24
+ from typing import TYPE_CHECKING, Callable, Iterable
25
+
26
+ from traceforge.governance.envelope import ContextGapEvent, EnrichedEvent
27
+ from traceforge.governance.results import SessionMeta
28
+
29
+ if TYPE_CHECKING:
30
+ import traceforge.types
31
+ from traceforge.sinks.base import StorageSink
32
+
33
+ logger = logging.getLogger(__name__)
34
+
35
+ # Synthetic gap markers bypass enrichment, so they carry an empty SessionMeta
36
+ # (serializes to ``"_governance": {}``). Reused — SessionMeta is immutable data.
37
+ _EMPTY_META = SessionMeta(classification=None, risk_assessment=None)
38
+
39
+ DEFAULT_CAPACITY = 1024
40
+
41
+
42
+ class _GapAccumulator:
43
+ """Coalesces consecutive dropped events for one session into one marker.
44
+
45
+ The durable drop *counter* is precise (``record_drop`` fires once per dropped
46
+ event); the emitted *marker* is coalesced so a burst of drops surfaces as a
47
+ single :class:`ContextGapEvent` spanning ``first``..``last`` sequence.
48
+ """
49
+
50
+ __slots__ = ("session_id", "count", "first_sequence", "last_sequence", "gap_ordinal")
51
+
52
+ def __init__(self, session_id: str) -> None:
53
+ self.session_id = session_id
54
+ self.count = 0
55
+ self.first_sequence: int | None = None
56
+ self.last_sequence: int | None = None
57
+ self.gap_ordinal = 0
58
+
59
+ def add(self, sequence: int | None, gap_ordinal: int) -> None:
60
+ self.count += 1
61
+ self.gap_ordinal = gap_ordinal
62
+ if sequence is not None:
63
+ if self.first_sequence is None:
64
+ self.first_sequence = sequence
65
+ self.last_sequence = sequence
66
+
67
+ def to_event(self) -> ContextGapEvent:
68
+ key = ContextGapEvent.compute_source_event_key(
69
+ self.session_id,
70
+ self.first_sequence,
71
+ self.last_sequence,
72
+ self.gap_ordinal,
73
+ )
74
+ return ContextGapEvent(
75
+ id=f"gap-{uuid.uuid4().hex[:12]}",
76
+ session_id=self.session_id,
77
+ timestamp=datetime.now(timezone.utc),
78
+ source_event_key=key,
79
+ dropped_count=self.count,
80
+ first_dropped_sequence=self.first_sequence,
81
+ last_dropped_sequence=self.last_sequence,
82
+ gap_ordinal=self.gap_ordinal,
83
+ )
84
+
85
+
86
+ class EnrichedEmitter:
87
+ """Bounded async actor: wraps ``(event, meta)`` in :class:`EnrichedEvent` and
88
+ fans it out to sinks via ``on_enriched_event``, dropping the oldest audit
89
+ record under backpressure.
90
+
91
+ Runs on a single event loop; :meth:`submit` is synchronous and non-blocking
92
+ (safe to call from inside the observer's async hooks). Because everything runs
93
+ single-threaded on the loop, ``submit`` executes atomically with respect to the
94
+ drain task — no locks are needed.
95
+ """
96
+
97
+ def __init__(
98
+ self,
99
+ sinks: "Iterable[StorageSink]",
100
+ *,
101
+ capacity: int = DEFAULT_CAPACITY,
102
+ record_drop: "Callable[[str, int], None] | None" = None,
103
+ ) -> None:
104
+ if capacity < 1:
105
+ raise ValueError("capacity must be >= 1")
106
+ self._sinks: list[StorageSink] = list(sinks)
107
+ self._capacity = capacity
108
+ self._record_drop = record_drop
109
+ self._queue: asyncio.Queue = asyncio.Queue(maxsize=capacity)
110
+ # Per-session coalescing of dropped events, flushed just before that
111
+ # session's next surviving event (and any trailing remainder at aclose).
112
+ self._pending_gaps: dict[str, _GapAccumulator] = {}
113
+ self._gap_ordinals: dict[str, int] = {}
114
+ self._drain_task: asyncio.Task | None = None
115
+ self._closed = False
116
+
117
+ @property
118
+ def capacity(self) -> int:
119
+ return self._capacity
120
+
121
+ async def start(self) -> None:
122
+ """Begin draining. Idempotent; must be called inside the event loop that
123
+ will own emission."""
124
+ if self._drain_task is None and not self._closed:
125
+ self._drain_task = asyncio.create_task(self._drain_loop())
126
+
127
+ def submit(self, event: "traceforge.types.SessionEvent", meta: SessionMeta) -> None:
128
+ """Enqueue an already-enriched ``(event, meta)`` for emission.
129
+
130
+ Synchronous and non-blocking. On a full queue, drop the **oldest**
131
+ surviving audit record (enforcement already happened in the observer, so
132
+ only audit emission is lost), record it durably, coalesce it into the
133
+ session's pending gap marker, then enqueue the new item.
134
+ """
135
+ item = (event, meta)
136
+ try:
137
+ self._queue.put_nowait(item)
138
+ return
139
+ except asyncio.QueueFull:
140
+ pass
141
+ try:
142
+ dropped_event, _ = self._queue.get_nowait()
143
+ # Balance the unfinished-task count for the put() that enqueued the
144
+ # dropped item, so aclose()'s queue.join() cannot hang.
145
+ self._queue.task_done()
146
+ except asyncio.QueueEmpty:
147
+ dropped_event = None
148
+ if dropped_event is not None:
149
+ self._record_dropped(dropped_event)
150
+ try:
151
+ self._queue.put_nowait(item)
152
+ except asyncio.QueueFull:
153
+ # We just freed a slot, so this should not happen; if it somehow does,
154
+ # count the *new* item as dropped rather than block the caller.
155
+ self._record_dropped(event)
156
+
157
+ def _record_dropped(self, event: "traceforge.types.SessionEvent") -> None:
158
+ sid = getattr(event, "session_id", "") or ""
159
+ sequence = None
160
+ metadata = getattr(event, "metadata", None)
161
+ if metadata is not None:
162
+ sequence = getattr(metadata, "sequence", None)
163
+ ordinal = self._gap_ordinals.get(sid, 0) + 1
164
+ self._gap_ordinals[sid] = ordinal
165
+ acc = self._pending_gaps.get(sid)
166
+ if acc is None:
167
+ acc = _GapAccumulator(sid)
168
+ self._pending_gaps[sid] = acc
169
+ acc.add(sequence, ordinal)
170
+ if self._record_drop is not None:
171
+ try:
172
+ self._record_drop(sid, 1)
173
+ except Exception as exc: # never let a persistence hiccup break submit
174
+ logger.error("record_drop failed for session %s: %s", sid, exc)
175
+
176
+ async def _drain_loop(self) -> None:
177
+ while True:
178
+ event, meta = await self._queue.get()
179
+ try:
180
+ await self._emit(event, meta)
181
+ finally:
182
+ self._queue.task_done()
183
+
184
+ async def _emit(self, event: "traceforge.types.SessionEvent", meta: SessionMeta) -> None:
185
+ # Flush this session's coalesced gap (if any) *before* the surviving event,
186
+ # so downstream sees the gap marker in order ahead of the next real record.
187
+ sid = getattr(event, "session_id", "") or ""
188
+ acc = self._pending_gaps.pop(sid, None)
189
+ if acc is not None:
190
+ await self._fanout_enriched(EnrichedEvent(event=acc.to_event(), governance=_EMPTY_META))
191
+ await self._fanout_enriched(EnrichedEvent(event=event, governance=meta))
192
+
193
+ async def _fanout_enriched(self, enriched: EnrichedEvent) -> None:
194
+ results = await asyncio.gather(
195
+ *(sink.on_enriched_event(enriched) for sink in self._sinks),
196
+ return_exceptions=True,
197
+ )
198
+ self._log_sink_errors(results, "on_enriched_event")
199
+
200
+ def _log_sink_errors(self, results: list, op: str) -> None:
201
+ for sink, result in zip(self._sinks, results):
202
+ if isinstance(result, BaseException):
203
+ logger.error(
204
+ "Sink %s failed during %s: %s",
205
+ type(sink).__name__,
206
+ op,
207
+ result,
208
+ exc_info=(type(result), result, result.__traceback__),
209
+ )
210
+
211
+ async def aclose(self) -> None:
212
+ """Drain everything already submitted, flush trailing gap markers, then
213
+ flush sinks. Idempotent."""
214
+ if self._closed:
215
+ return
216
+ self._closed = True
217
+ if self._drain_task is not None:
218
+ # Wait for all queued items to be emitted, then stop the loop.
219
+ await self._queue.join()
220
+ self._drain_task.cancel()
221
+ try:
222
+ await self._drain_task
223
+ except asyncio.CancelledError:
224
+ pass
225
+ self._drain_task = None
226
+ # Any coalesced gaps whose surviving event never arrived still deserve a
227
+ # downstream marker.
228
+ for sid in list(self._pending_gaps.keys()):
229
+ acc = self._pending_gaps.pop(sid)
230
+ await self._fanout_enriched(EnrichedEvent(event=acc.to_event(), governance=_EMPTY_META))
231
+ results = await asyncio.gather(
232
+ *(sink.flush() for sink in self._sinks), return_exceptions=True
233
+ )
234
+ self._log_sink_errors(results, "flush")
@@ -0,0 +1,138 @@
1
+ """Enriched event envelope, ContextGapEvent, and sink emission types."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass
6
+ from datetime import datetime
7
+ from typing import TYPE_CHECKING, Literal
8
+
9
+ if TYPE_CHECKING:
10
+ import traceforge.types
11
+
12
+ from traceforge.governance.pipeline import SessionMeta
13
+ from traceforge.governance.types import SessionEvent
14
+
15
+
16
+ @dataclass(frozen=True)
17
+ class ContextGapEvent:
18
+ """Synthetic marker emitted when events are dropped due to backpressure.
19
+
20
+ Does NOT flow through full enrichment pipeline — bypasses Phase 2/3.
21
+ Serialized directly to sinks as-is.
22
+ """
23
+
24
+ id: str
25
+ session_id: str
26
+ timestamp: datetime
27
+ source_event_key: str
28
+ kind: Literal["context_gap"] = "context_gap"
29
+ dropped_count: int = 0
30
+ first_dropped_sequence: int | None = None
31
+ last_dropped_sequence: int | None = None
32
+ gap_ordinal: int = 0
33
+ reason: str = "backpressure"
34
+
35
+ @staticmethod
36
+ def compute_source_event_key(
37
+ session_id: str,
38
+ first_sequence: int | None,
39
+ last_sequence: int | None,
40
+ gap_ordinal: int,
41
+ ) -> str:
42
+ """Deterministic key derivation for gap events."""
43
+ if first_sequence is not None and last_sequence is not None:
44
+ return f"gap:{session_id}:{first_sequence}:{last_sequence}"
45
+ return f"gap:{session_id}:ord:{gap_ordinal}"
46
+
47
+
48
+ @dataclass(frozen=True)
49
+ class EnrichedEvent:
50
+ """Immutable envelope for sink emission. Event is unmodified; governance is attached alongside.
51
+
52
+ Sinks serialize this as {"event": {...}, "_governance": {...}} or equivalent per sink type.
53
+ """
54
+
55
+ event: "traceforge.types.SessionEvent | SessionEvent | ContextGapEvent"
56
+ governance: "SessionMeta"
57
+
58
+ def to_dict(self) -> dict:
59
+ """Serialize for sink emission."""
60
+ event_dict: dict
61
+ if isinstance(self.event, ContextGapEvent):
62
+ event_dict = {
63
+ "kind": self.event.kind,
64
+ "id": self.event.id,
65
+ "session_id": self.event.session_id,
66
+ "timestamp": self.event.timestamp.isoformat(),
67
+ "dropped_count": self.event.dropped_count,
68
+ "first_dropped_sequence": self.event.first_dropped_sequence,
69
+ "last_dropped_sequence": self.event.last_dropped_sequence,
70
+ "gap_ordinal": self.event.gap_ordinal,
71
+ "reason": self.event.reason,
72
+ }
73
+ elif hasattr(self.event, "model_dump"):
74
+ # Live traceforge.types.SessionEvent (pydantic). Mirror JsonlSink's
75
+ # event body, but lift governance out of metadata into the top-level
76
+ # ``_governance`` slot so the event stays the raw structural record
77
+ # and governance is not duplicated inside it.
78
+ live = self.event
79
+ metadata_dict = (
80
+ live.metadata.model_dump(exclude_none=True) if live.metadata is not None else None
81
+ )
82
+ if isinstance(metadata_dict, dict):
83
+ metadata_dict.pop("governance", None)
84
+ event_dict = {
85
+ "id": live.id,
86
+ "kind": live.kind,
87
+ "session_id": live.session_id,
88
+ "timestamp": live.timestamp.isoformat(),
89
+ "payload": live.payload,
90
+ "metadata": metadata_dict,
91
+ }
92
+ else:
93
+ event_dict = {
94
+ "event_id": self.event.event_id,
95
+ "session_id": self.event.session_id,
96
+ "timestamp": self.event.timestamp.isoformat(),
97
+ "source_event_key": self.event.source_event_key,
98
+ }
99
+
100
+ governance_dict = {}
101
+ if self.governance.classification is not None:
102
+ governance_dict["classification"] = self.governance.classification.to_dict()
103
+ if self.governance.risk_assessment is not None:
104
+ ra = self.governance.risk_assessment
105
+ governance_dict["risk_assessment"] = {
106
+ "score": ra.score,
107
+ "level": ra.level,
108
+ "confidence": ra.confidence,
109
+ "factors": list(ra.factors),
110
+ "mitre": list(ra.mitre),
111
+ }
112
+ if self.governance.recommendation is not None:
113
+ rec = self.governance.recommendation
114
+ governance_dict["recommendation"] = {
115
+ "action": rec.recommended_action.value,
116
+ "reason_code": rec.reason_code,
117
+ "canonical_id": rec.canonical_id,
118
+ }
119
+ if self.governance.evidence is not None:
120
+ ev = self.governance.evidence
121
+ governance_dict["evidence"] = {
122
+ "canonical_id": ev.canonical_id,
123
+ "recommended_action": ev.recommended_action.value,
124
+ "risk_score": ev.risk_score,
125
+ "mechanism": ev.mechanism,
126
+ "effect": ev.effect,
127
+ "scope": list(ev.scope),
128
+ "capability": list(ev.capability),
129
+ "structure": list(ev.structure),
130
+ }
131
+ if self.governance.budget_snapshot is not None:
132
+ bs = self.governance.budget_snapshot
133
+ governance_dict["budget"] = {
134
+ "total_tool_calls": bs.total_tool_calls,
135
+ "pressure": bs.pressure,
136
+ }
137
+
138
+ return {"event": event_dict, "_governance": governance_dict}