truthound-dashboard 1.3.1__py3-none-any.whl → 1.4.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 (169) hide show
  1. truthound_dashboard/api/alerts.py +258 -0
  2. truthound_dashboard/api/anomaly.py +1302 -0
  3. truthound_dashboard/api/cross_alerts.py +352 -0
  4. truthound_dashboard/api/deps.py +143 -0
  5. truthound_dashboard/api/drift_monitor.py +540 -0
  6. truthound_dashboard/api/lineage.py +1151 -0
  7. truthound_dashboard/api/maintenance.py +363 -0
  8. truthound_dashboard/api/middleware.py +373 -1
  9. truthound_dashboard/api/model_monitoring.py +805 -0
  10. truthound_dashboard/api/notifications_advanced.py +2452 -0
  11. truthound_dashboard/api/plugins.py +2096 -0
  12. truthound_dashboard/api/profile.py +211 -14
  13. truthound_dashboard/api/reports.py +853 -0
  14. truthound_dashboard/api/router.py +147 -0
  15. truthound_dashboard/api/rule_suggestions.py +310 -0
  16. truthound_dashboard/api/schema_evolution.py +231 -0
  17. truthound_dashboard/api/sources.py +47 -3
  18. truthound_dashboard/api/triggers.py +190 -0
  19. truthound_dashboard/api/validations.py +13 -0
  20. truthound_dashboard/api/validators.py +333 -4
  21. truthound_dashboard/api/versioning.py +309 -0
  22. truthound_dashboard/api/websocket.py +301 -0
  23. truthound_dashboard/core/__init__.py +27 -0
  24. truthound_dashboard/core/anomaly.py +1395 -0
  25. truthound_dashboard/core/anomaly_explainer.py +633 -0
  26. truthound_dashboard/core/cache.py +206 -0
  27. truthound_dashboard/core/cached_services.py +422 -0
  28. truthound_dashboard/core/charts.py +352 -0
  29. truthound_dashboard/core/connections.py +1069 -42
  30. truthound_dashboard/core/cross_alerts.py +837 -0
  31. truthound_dashboard/core/drift_monitor.py +1477 -0
  32. truthound_dashboard/core/drift_sampling.py +669 -0
  33. truthound_dashboard/core/i18n/__init__.py +42 -0
  34. truthound_dashboard/core/i18n/detector.py +173 -0
  35. truthound_dashboard/core/i18n/messages.py +564 -0
  36. truthound_dashboard/core/lineage.py +971 -0
  37. truthound_dashboard/core/maintenance.py +443 -5
  38. truthound_dashboard/core/model_monitoring.py +1043 -0
  39. truthound_dashboard/core/notifications/channels.py +1020 -1
  40. truthound_dashboard/core/notifications/deduplication/__init__.py +143 -0
  41. truthound_dashboard/core/notifications/deduplication/policies.py +274 -0
  42. truthound_dashboard/core/notifications/deduplication/service.py +400 -0
  43. truthound_dashboard/core/notifications/deduplication/stores.py +2365 -0
  44. truthound_dashboard/core/notifications/deduplication/strategies.py +422 -0
  45. truthound_dashboard/core/notifications/dispatcher.py +43 -0
  46. truthound_dashboard/core/notifications/escalation/__init__.py +149 -0
  47. truthound_dashboard/core/notifications/escalation/backends.py +1384 -0
  48. truthound_dashboard/core/notifications/escalation/engine.py +429 -0
  49. truthound_dashboard/core/notifications/escalation/models.py +336 -0
  50. truthound_dashboard/core/notifications/escalation/scheduler.py +1187 -0
  51. truthound_dashboard/core/notifications/escalation/state_machine.py +330 -0
  52. truthound_dashboard/core/notifications/escalation/stores.py +2896 -0
  53. truthound_dashboard/core/notifications/events.py +49 -0
  54. truthound_dashboard/core/notifications/metrics/__init__.py +115 -0
  55. truthound_dashboard/core/notifications/metrics/base.py +528 -0
  56. truthound_dashboard/core/notifications/metrics/collectors.py +583 -0
  57. truthound_dashboard/core/notifications/routing/__init__.py +169 -0
  58. truthound_dashboard/core/notifications/routing/combinators.py +184 -0
  59. truthound_dashboard/core/notifications/routing/config.py +375 -0
  60. truthound_dashboard/core/notifications/routing/config_parser.py +867 -0
  61. truthound_dashboard/core/notifications/routing/engine.py +382 -0
  62. truthound_dashboard/core/notifications/routing/expression_engine.py +1269 -0
  63. truthound_dashboard/core/notifications/routing/jinja2_engine.py +774 -0
  64. truthound_dashboard/core/notifications/routing/rules.py +625 -0
  65. truthound_dashboard/core/notifications/routing/validator.py +678 -0
  66. truthound_dashboard/core/notifications/service.py +2 -0
  67. truthound_dashboard/core/notifications/stats_aggregator.py +850 -0
  68. truthound_dashboard/core/notifications/throttling/__init__.py +83 -0
  69. truthound_dashboard/core/notifications/throttling/builder.py +311 -0
  70. truthound_dashboard/core/notifications/throttling/stores.py +1859 -0
  71. truthound_dashboard/core/notifications/throttling/throttlers.py +633 -0
  72. truthound_dashboard/core/openlineage.py +1028 -0
  73. truthound_dashboard/core/plugins/__init__.py +39 -0
  74. truthound_dashboard/core/plugins/docs/__init__.py +39 -0
  75. truthound_dashboard/core/plugins/docs/extractor.py +703 -0
  76. truthound_dashboard/core/plugins/docs/renderers.py +804 -0
  77. truthound_dashboard/core/plugins/hooks/__init__.py +63 -0
  78. truthound_dashboard/core/plugins/hooks/decorators.py +367 -0
  79. truthound_dashboard/core/plugins/hooks/manager.py +403 -0
  80. truthound_dashboard/core/plugins/hooks/protocols.py +265 -0
  81. truthound_dashboard/core/plugins/lifecycle/__init__.py +41 -0
  82. truthound_dashboard/core/plugins/lifecycle/hot_reload.py +584 -0
  83. truthound_dashboard/core/plugins/lifecycle/machine.py +419 -0
  84. truthound_dashboard/core/plugins/lifecycle/states.py +266 -0
  85. truthound_dashboard/core/plugins/loader.py +504 -0
  86. truthound_dashboard/core/plugins/registry.py +810 -0
  87. truthound_dashboard/core/plugins/reporter_executor.py +588 -0
  88. truthound_dashboard/core/plugins/sandbox/__init__.py +59 -0
  89. truthound_dashboard/core/plugins/sandbox/code_validator.py +243 -0
  90. truthound_dashboard/core/plugins/sandbox/engines.py +770 -0
  91. truthound_dashboard/core/plugins/sandbox/protocols.py +194 -0
  92. truthound_dashboard/core/plugins/sandbox.py +617 -0
  93. truthound_dashboard/core/plugins/security/__init__.py +68 -0
  94. truthound_dashboard/core/plugins/security/analyzer.py +535 -0
  95. truthound_dashboard/core/plugins/security/policies.py +311 -0
  96. truthound_dashboard/core/plugins/security/protocols.py +296 -0
  97. truthound_dashboard/core/plugins/security/signing.py +842 -0
  98. truthound_dashboard/core/plugins/security.py +446 -0
  99. truthound_dashboard/core/plugins/validator_executor.py +401 -0
  100. truthound_dashboard/core/plugins/versioning/__init__.py +51 -0
  101. truthound_dashboard/core/plugins/versioning/constraints.py +377 -0
  102. truthound_dashboard/core/plugins/versioning/dependencies.py +541 -0
  103. truthound_dashboard/core/plugins/versioning/semver.py +266 -0
  104. truthound_dashboard/core/profile_comparison.py +601 -0
  105. truthound_dashboard/core/report_history.py +570 -0
  106. truthound_dashboard/core/reporters/__init__.py +57 -0
  107. truthound_dashboard/core/reporters/base.py +296 -0
  108. truthound_dashboard/core/reporters/csv_reporter.py +155 -0
  109. truthound_dashboard/core/reporters/html_reporter.py +598 -0
  110. truthound_dashboard/core/reporters/i18n/__init__.py +65 -0
  111. truthound_dashboard/core/reporters/i18n/base.py +494 -0
  112. truthound_dashboard/core/reporters/i18n/catalogs.py +930 -0
  113. truthound_dashboard/core/reporters/json_reporter.py +160 -0
  114. truthound_dashboard/core/reporters/junit_reporter.py +233 -0
  115. truthound_dashboard/core/reporters/markdown_reporter.py +207 -0
  116. truthound_dashboard/core/reporters/pdf_reporter.py +209 -0
  117. truthound_dashboard/core/reporters/registry.py +272 -0
  118. truthound_dashboard/core/rule_generator.py +2088 -0
  119. truthound_dashboard/core/scheduler.py +822 -12
  120. truthound_dashboard/core/schema_evolution.py +858 -0
  121. truthound_dashboard/core/services.py +152 -9
  122. truthound_dashboard/core/statistics.py +718 -0
  123. truthound_dashboard/core/streaming_anomaly.py +883 -0
  124. truthound_dashboard/core/triggers/__init__.py +45 -0
  125. truthound_dashboard/core/triggers/base.py +226 -0
  126. truthound_dashboard/core/triggers/evaluators.py +609 -0
  127. truthound_dashboard/core/triggers/factory.py +363 -0
  128. truthound_dashboard/core/unified_alerts.py +870 -0
  129. truthound_dashboard/core/validation_limits.py +509 -0
  130. truthound_dashboard/core/versioning.py +709 -0
  131. truthound_dashboard/core/websocket/__init__.py +59 -0
  132. truthound_dashboard/core/websocket/manager.py +512 -0
  133. truthound_dashboard/core/websocket/messages.py +130 -0
  134. truthound_dashboard/db/__init__.py +30 -0
  135. truthound_dashboard/db/models.py +3375 -3
  136. truthound_dashboard/main.py +22 -0
  137. truthound_dashboard/schemas/__init__.py +396 -1
  138. truthound_dashboard/schemas/anomaly.py +1258 -0
  139. truthound_dashboard/schemas/base.py +4 -0
  140. truthound_dashboard/schemas/cross_alerts.py +334 -0
  141. truthound_dashboard/schemas/drift_monitor.py +890 -0
  142. truthound_dashboard/schemas/lineage.py +428 -0
  143. truthound_dashboard/schemas/maintenance.py +154 -0
  144. truthound_dashboard/schemas/model_monitoring.py +374 -0
  145. truthound_dashboard/schemas/notifications_advanced.py +1363 -0
  146. truthound_dashboard/schemas/openlineage.py +704 -0
  147. truthound_dashboard/schemas/plugins.py +1293 -0
  148. truthound_dashboard/schemas/profile.py +420 -34
  149. truthound_dashboard/schemas/profile_comparison.py +242 -0
  150. truthound_dashboard/schemas/reports.py +285 -0
  151. truthound_dashboard/schemas/rule_suggestion.py +434 -0
  152. truthound_dashboard/schemas/schema_evolution.py +164 -0
  153. truthound_dashboard/schemas/source.py +117 -2
  154. truthound_dashboard/schemas/triggers.py +511 -0
  155. truthound_dashboard/schemas/unified_alerts.py +223 -0
  156. truthound_dashboard/schemas/validation.py +25 -1
  157. truthound_dashboard/schemas/validators/__init__.py +11 -0
  158. truthound_dashboard/schemas/validators/base.py +151 -0
  159. truthound_dashboard/schemas/versioning.py +152 -0
  160. truthound_dashboard/static/index.html +2 -2
  161. {truthound_dashboard-1.3.1.dist-info → truthound_dashboard-1.4.0.dist-info}/METADATA +142 -22
  162. truthound_dashboard-1.4.0.dist-info/RECORD +239 -0
  163. truthound_dashboard/static/assets/index-BZG20KuF.js +0 -586
  164. truthound_dashboard/static/assets/index-D_HyZ3pb.css +0 -1
  165. truthound_dashboard/static/assets/unmerged_dictionaries-CtpqQBm0.js +0 -1
  166. truthound_dashboard-1.3.1.dist-info/RECORD +0 -110
  167. {truthound_dashboard-1.3.1.dist-info → truthound_dashboard-1.4.0.dist-info}/WHEEL +0 -0
  168. {truthound_dashboard-1.3.1.dist-info → truthound_dashboard-1.4.0.dist-info}/entry_points.txt +0 -0
  169. {truthound_dashboard-1.3.1.dist-info → truthound_dashboard-1.4.0.dist-info}/licenses/LICENSE +0 -0
@@ -0,0 +1,678 @@
1
+ """Rule configuration validator with circular reference detection.
2
+
3
+ This module provides comprehensive validation for routing rule configurations,
4
+ including:
5
+ - Circular reference detection (direct and indirect)
6
+ - Maximum nesting depth limits
7
+ - Maximum rules per combinator limits
8
+ - Reserved field name validation
9
+ - Rule type validation against RuleRegistry
10
+
11
+ Example:
12
+ from truthound_dashboard.core.notifications.routing.validator import (
13
+ RuleValidator,
14
+ RuleValidationConfig,
15
+ RuleValidationError,
16
+ )
17
+
18
+ validator = RuleValidator()
19
+ result = validator.validate(rule_config)
20
+
21
+ if not result.valid:
22
+ for error in result.errors:
23
+ print(f"Error: {error.message} at {error.path}")
24
+ """
25
+
26
+ from __future__ import annotations
27
+
28
+ from dataclasses import dataclass, field
29
+ from enum import Enum
30
+ from typing import Any
31
+
32
+
33
+ class ValidationErrorType(str, Enum):
34
+ """Types of validation errors."""
35
+
36
+ UNKNOWN_RULE_TYPE = "unknown_rule_type"
37
+ MISSING_REQUIRED_FIELD = "missing_required_field"
38
+ INVALID_FIELD_VALUE = "invalid_field_value"
39
+ CIRCULAR_REFERENCE = "circular_reference"
40
+ MAX_DEPTH_EXCEEDED = "max_depth_exceeded"
41
+ MAX_RULES_EXCEEDED = "max_rules_exceeded"
42
+ RESERVED_FIELD_NAME = "reserved_field_name"
43
+ EMPTY_COMBINATOR = "empty_combinator"
44
+ INVALID_STRUCTURE = "invalid_structure"
45
+
46
+
47
+ @dataclass
48
+ class ValidationError:
49
+ """A single validation error with context.
50
+
51
+ Attributes:
52
+ type: The type of validation error.
53
+ message: Human-readable error message.
54
+ path: JSON path to the error location (e.g., "rules[0].rules[1]").
55
+ context: Additional context about the error.
56
+ """
57
+
58
+ type: ValidationErrorType
59
+ message: str
60
+ path: str = ""
61
+ context: dict[str, Any] = field(default_factory=dict)
62
+
63
+ def to_dict(self) -> dict[str, Any]:
64
+ """Convert to dictionary representation."""
65
+ return {
66
+ "type": self.type.value,
67
+ "message": self.message,
68
+ "path": self.path,
69
+ "context": self.context,
70
+ }
71
+
72
+
73
+ @dataclass
74
+ class ValidationWarning:
75
+ """A single validation warning.
76
+
77
+ Attributes:
78
+ message: Human-readable warning message.
79
+ path: JSON path to the warning location.
80
+ """
81
+
82
+ message: str
83
+ path: str = ""
84
+
85
+ def to_dict(self) -> dict[str, Any]:
86
+ """Convert to dictionary representation."""
87
+ return {
88
+ "message": self.message,
89
+ "path": self.path,
90
+ }
91
+
92
+
93
+ @dataclass
94
+ class RuleValidationConfig:
95
+ """Configuration for rule validation.
96
+
97
+ Attributes:
98
+ max_depth: Maximum allowed nesting depth (default: 10).
99
+ max_rules_per_combinator: Maximum rules in a single combinator (default: 50).
100
+ max_total_rules: Maximum total rules in the configuration (default: 500).
101
+ reserved_field_names: Set of reserved field names that cannot be used.
102
+ check_circular_refs: Whether to check for circular references (default: True).
103
+ strict_mode: If True, warnings are treated as errors (default: False).
104
+ """
105
+
106
+ max_depth: int = 10
107
+ max_rules_per_combinator: int = 50
108
+ max_total_rules: int = 500
109
+ reserved_field_names: set[str] = field(
110
+ default_factory=lambda: {
111
+ "__proto__",
112
+ "constructor",
113
+ "prototype",
114
+ "__class__",
115
+ "__bases__",
116
+ "__mro__",
117
+ "__subclasses__",
118
+ "__init__",
119
+ "__new__",
120
+ "__del__",
121
+ "__call__",
122
+ "__getattr__",
123
+ "__setattr__",
124
+ "__delattr__",
125
+ "__dict__",
126
+ "__slots__",
127
+ "__module__",
128
+ "__name__",
129
+ "__qualname__",
130
+ "__globals__",
131
+ "__code__",
132
+ "__builtins__",
133
+ "__import__",
134
+ "eval",
135
+ "exec",
136
+ "compile",
137
+ }
138
+ )
139
+ check_circular_refs: bool = True
140
+ strict_mode: bool = False
141
+
142
+
143
+ @dataclass
144
+ class RuleValidationResult:
145
+ """Result of rule configuration validation.
146
+
147
+ Attributes:
148
+ valid: Whether the configuration is valid.
149
+ errors: List of validation errors.
150
+ warnings: List of validation warnings.
151
+ rule_count: Total number of rules (including nested).
152
+ max_depth: Maximum nesting depth found.
153
+ circular_paths: Paths of detected circular references.
154
+ """
155
+
156
+ valid: bool = True
157
+ errors: list[ValidationError] = field(default_factory=list)
158
+ warnings: list[ValidationWarning] = field(default_factory=list)
159
+ rule_count: int = 0
160
+ max_depth: int = 0
161
+ circular_paths: list[str] = field(default_factory=list)
162
+
163
+ def add_error(
164
+ self,
165
+ error_type: ValidationErrorType,
166
+ message: str,
167
+ path: str = "",
168
+ context: dict[str, Any] | None = None,
169
+ ) -> None:
170
+ """Add a validation error."""
171
+ self.valid = False
172
+ self.errors.append(
173
+ ValidationError(
174
+ type=error_type,
175
+ message=message,
176
+ path=path,
177
+ context=context or {},
178
+ )
179
+ )
180
+
181
+ def add_warning(self, message: str, path: str = "") -> None:
182
+ """Add a validation warning."""
183
+ self.warnings.append(ValidationWarning(message=message, path=path))
184
+
185
+ def to_dict(self) -> dict[str, Any]:
186
+ """Convert to dictionary representation."""
187
+ return {
188
+ "valid": self.valid,
189
+ "errors": [e.to_dict() for e in self.errors],
190
+ "warnings": [w.to_dict() for w in self.warnings],
191
+ "rule_count": self.rule_count,
192
+ "max_depth": self.max_depth,
193
+ "circular_paths": self.circular_paths,
194
+ }
195
+
196
+ def error_messages(self) -> list[str]:
197
+ """Get list of error messages for backward compatibility."""
198
+ return [e.message for e in self.errors]
199
+
200
+ def warning_messages(self) -> list[str]:
201
+ """Get list of warning messages for backward compatibility."""
202
+ return [w.message for w in self.warnings]
203
+
204
+
205
+ class RuleValidationError(Exception):
206
+ """Exception raised for critical validation failures.
207
+
208
+ Attributes:
209
+ result: The validation result with error details.
210
+ """
211
+
212
+ def __init__(self, message: str, result: RuleValidationResult):
213
+ super().__init__(message)
214
+ self.result = result
215
+
216
+
217
+ class RuleValidator:
218
+ """Comprehensive validator for routing rule configurations.
219
+
220
+ Provides validation including:
221
+ - Rule type existence (via RuleRegistry)
222
+ - Required parameter validation
223
+ - Circular reference detection (direct and indirect)
224
+ - Maximum nesting depth enforcement
225
+ - Maximum rules per combinator enforcement
226
+ - Reserved field name checking
227
+
228
+ Example:
229
+ validator = RuleValidator()
230
+ result = validator.validate({
231
+ "type": "all_of",
232
+ "rules": [
233
+ {"type": "severity", "params": {"min_severity": "critical"}},
234
+ {"type": "tag", "params": {"tags": ["production"]}},
235
+ ]
236
+ })
237
+
238
+ if result.valid:
239
+ print("Configuration is valid")
240
+ else:
241
+ for error in result.errors:
242
+ print(f"Error: {error.message}")
243
+ """
244
+
245
+ # Combinator types that can contain nested rules
246
+ COMBINATOR_TYPES = {"all_of", "any_of", "not"}
247
+
248
+ def __init__(self, config: RuleValidationConfig | None = None):
249
+ """Initialize the validator.
250
+
251
+ Args:
252
+ config: Validation configuration. Uses defaults if not provided.
253
+ """
254
+ self.config = config or RuleValidationConfig()
255
+
256
+ def validate(
257
+ self,
258
+ rule_config: dict[str, Any],
259
+ config_override: RuleValidationConfig | None = None,
260
+ ) -> RuleValidationResult:
261
+ """Validate a rule configuration.
262
+
263
+ Args:
264
+ rule_config: The rule configuration dictionary to validate.
265
+ config_override: Optional configuration override for this validation.
266
+
267
+ Returns:
268
+ RuleValidationResult with validation details.
269
+ """
270
+ config = config_override or self.config
271
+ result = RuleValidationResult()
272
+
273
+ # Track visited nodes for circular reference detection
274
+ # Using a path-based approach for accurate cycle detection
275
+ visited_paths: set[str] = set()
276
+
277
+ # Perform recursive validation
278
+ self._validate_rule_recursive(
279
+ rule_config=rule_config,
280
+ result=result,
281
+ config=config,
282
+ depth=0,
283
+ path="",
284
+ visited_paths=visited_paths,
285
+ parent_chain=[],
286
+ )
287
+
288
+ # Apply strict mode if configured
289
+ if config.strict_mode and result.warnings:
290
+ for warning in result.warnings:
291
+ result.add_error(
292
+ ValidationErrorType.INVALID_FIELD_VALUE,
293
+ f"Strict mode: {warning.message}",
294
+ warning.path,
295
+ )
296
+
297
+ return result
298
+
299
+ def _validate_rule_recursive(
300
+ self,
301
+ rule_config: dict[str, Any],
302
+ result: RuleValidationResult,
303
+ config: RuleValidationConfig,
304
+ depth: int,
305
+ path: str,
306
+ visited_paths: set[str],
307
+ parent_chain: list[str],
308
+ ) -> None:
309
+ """Recursively validate a rule configuration.
310
+
311
+ Uses DFS with visited set for efficient cycle detection.
312
+
313
+ Args:
314
+ rule_config: The rule configuration to validate.
315
+ result: Validation result to update.
316
+ config: Validation configuration.
317
+ depth: Current nesting depth.
318
+ path: Current JSON path for error reporting.
319
+ visited_paths: Set of visited paths for cycle detection.
320
+ parent_chain: Chain of parent rule IDs for cycle path reporting.
321
+ """
322
+ from .rules import RuleRegistry
323
+
324
+ result.rule_count += 1
325
+ result.max_depth = max(result.max_depth, depth)
326
+
327
+ # Check total rules limit
328
+ if result.rule_count > config.max_total_rules:
329
+ result.add_error(
330
+ ValidationErrorType.MAX_RULES_EXCEEDED,
331
+ f"Total rule count ({result.rule_count}) exceeds maximum ({config.max_total_rules})",
332
+ path,
333
+ {"max_total_rules": config.max_total_rules, "current_count": result.rule_count},
334
+ )
335
+ return
336
+
337
+ # Validate structure
338
+ if not isinstance(rule_config, dict):
339
+ result.add_error(
340
+ ValidationErrorType.INVALID_STRUCTURE,
341
+ "Rule configuration must be a dictionary/object",
342
+ path,
343
+ )
344
+ return
345
+
346
+ # Get rule type
347
+ rule_type = rule_config.get("type")
348
+ if not rule_type:
349
+ result.add_error(
350
+ ValidationErrorType.MISSING_REQUIRED_FIELD,
351
+ "Rule missing required field 'type'",
352
+ path,
353
+ )
354
+ return
355
+
356
+ # Validate rule type string
357
+ if not isinstance(rule_type, str):
358
+ result.add_error(
359
+ ValidationErrorType.INVALID_FIELD_VALUE,
360
+ f"Rule 'type' must be a string, got {type(rule_type).__name__}",
361
+ path,
362
+ )
363
+ return
364
+
365
+ # Check for reserved field names in params
366
+ params = rule_config.get("params", {})
367
+ if isinstance(params, dict):
368
+ self._check_reserved_fields(params, result, config, f"{path}.params")
369
+
370
+ # Check nesting depth
371
+ if depth > config.max_depth:
372
+ result.add_error(
373
+ ValidationErrorType.MAX_DEPTH_EXCEEDED,
374
+ f"Maximum nesting depth ({config.max_depth}) exceeded at depth {depth}",
375
+ path,
376
+ {"max_depth": config.max_depth, "current_depth": depth},
377
+ )
378
+ return
379
+
380
+ # Create a unique identifier for this rule node
381
+ rule_id = self._generate_rule_id(rule_config, path)
382
+
383
+ # Circular reference detection using path-based tracking
384
+ if config.check_circular_refs:
385
+ if rule_id in visited_paths:
386
+ # Found a circular reference
387
+ cycle_path = self._format_cycle_path(parent_chain, rule_id)
388
+ result.add_error(
389
+ ValidationErrorType.CIRCULAR_REFERENCE,
390
+ f"Circular reference detected: {cycle_path}",
391
+ path,
392
+ {"cycle_path": cycle_path, "rule_id": rule_id},
393
+ )
394
+ result.circular_paths.append(cycle_path)
395
+ return
396
+
397
+ # Mark as visited
398
+ visited_paths.add(rule_id)
399
+ current_chain = parent_chain + [rule_id]
400
+
401
+ try:
402
+ # Validate based on rule type
403
+ if rule_type in self.COMBINATOR_TYPES:
404
+ self._validate_combinator(
405
+ rule_config=rule_config,
406
+ rule_type=rule_type,
407
+ result=result,
408
+ config=config,
409
+ depth=depth,
410
+ path=path,
411
+ visited_paths=visited_paths,
412
+ parent_chain=current_chain,
413
+ )
414
+ else:
415
+ self._validate_simple_rule(
416
+ rule_config=rule_config,
417
+ rule_type=rule_type,
418
+ result=result,
419
+ path=path,
420
+ )
421
+ finally:
422
+ # Unmark as visited (for DFS backtracking)
423
+ # This allows the same rule pattern to appear in different branches
424
+ visited_paths.discard(rule_id)
425
+
426
+ def _validate_combinator(
427
+ self,
428
+ rule_config: dict[str, Any],
429
+ rule_type: str,
430
+ result: RuleValidationResult,
431
+ config: RuleValidationConfig,
432
+ depth: int,
433
+ path: str,
434
+ visited_paths: set[str],
435
+ parent_chain: list[str],
436
+ ) -> None:
437
+ """Validate a combinator rule (all_of, any_of, not).
438
+
439
+ Args:
440
+ rule_config: The combinator rule configuration.
441
+ rule_type: The combinator type.
442
+ result: Validation result to update.
443
+ config: Validation configuration.
444
+ depth: Current nesting depth.
445
+ path: Current JSON path.
446
+ visited_paths: Set of visited paths.
447
+ parent_chain: Chain of parent rule IDs.
448
+ """
449
+ if rule_type == "not":
450
+ # NOT combinator requires a single 'rule' field
451
+ nested_rule = rule_config.get("rule") or rule_config.get("params", {}).get("rule")
452
+
453
+ if not nested_rule:
454
+ result.add_error(
455
+ ValidationErrorType.EMPTY_COMBINATOR,
456
+ "'not' combinator requires a 'rule' field",
457
+ path,
458
+ )
459
+ return
460
+
461
+ if not isinstance(nested_rule, dict):
462
+ result.add_error(
463
+ ValidationErrorType.INVALID_STRUCTURE,
464
+ "'not' combinator 'rule' must be an object",
465
+ path,
466
+ )
467
+ return
468
+
469
+ # Recursively validate the nested rule
470
+ self._validate_rule_recursive(
471
+ rule_config=nested_rule,
472
+ result=result,
473
+ config=config,
474
+ depth=depth + 1,
475
+ path=f"{path}.rule" if path else "rule",
476
+ visited_paths=visited_paths,
477
+ parent_chain=parent_chain,
478
+ )
479
+
480
+ else:
481
+ # ALL_OF or ANY_OF combinator requires a 'rules' array
482
+ nested_rules = rule_config.get("rules") or rule_config.get("params", {}).get("rules", [])
483
+
484
+ if not nested_rules:
485
+ result.add_error(
486
+ ValidationErrorType.EMPTY_COMBINATOR,
487
+ f"'{rule_type}' combinator requires a non-empty 'rules' array",
488
+ path,
489
+ )
490
+ return
491
+
492
+ if not isinstance(nested_rules, list):
493
+ result.add_error(
494
+ ValidationErrorType.INVALID_STRUCTURE,
495
+ f"'{rule_type}' combinator 'rules' must be an array",
496
+ path,
497
+ )
498
+ return
499
+
500
+ # Check max rules per combinator
501
+ if len(nested_rules) > config.max_rules_per_combinator:
502
+ result.add_error(
503
+ ValidationErrorType.MAX_RULES_EXCEEDED,
504
+ f"Combinator has {len(nested_rules)} rules, exceeds maximum of {config.max_rules_per_combinator}",
505
+ path,
506
+ {
507
+ "max_rules_per_combinator": config.max_rules_per_combinator,
508
+ "current_count": len(nested_rules),
509
+ },
510
+ )
511
+ return
512
+
513
+ # Warn about single-rule combinators
514
+ if len(nested_rules) == 1:
515
+ result.add_warning(
516
+ f"'{rule_type}' combinator with only 1 rule is redundant",
517
+ path,
518
+ )
519
+
520
+ # Recursively validate each nested rule
521
+ for idx, nested_rule in enumerate(nested_rules):
522
+ nested_path = f"{path}.rules[{idx}]" if path else f"rules[{idx}]"
523
+ self._validate_rule_recursive(
524
+ rule_config=nested_rule,
525
+ result=result,
526
+ config=config,
527
+ depth=depth + 1,
528
+ path=nested_path,
529
+ visited_paths=visited_paths,
530
+ parent_chain=parent_chain,
531
+ )
532
+
533
+ def _validate_simple_rule(
534
+ self,
535
+ rule_config: dict[str, Any],
536
+ rule_type: str,
537
+ result: RuleValidationResult,
538
+ path: str,
539
+ ) -> None:
540
+ """Validate a simple (non-combinator) rule.
541
+
542
+ Args:
543
+ rule_config: The rule configuration.
544
+ rule_type: The rule type.
545
+ result: Validation result to update.
546
+ path: Current JSON path.
547
+ """
548
+ from .rules import RuleRegistry
549
+
550
+ # Check if rule type is registered
551
+ rule_class = RuleRegistry.get(rule_type)
552
+ if rule_class is None:
553
+ available_types = RuleRegistry.list_types()
554
+ result.add_error(
555
+ ValidationErrorType.UNKNOWN_RULE_TYPE,
556
+ f"Unknown rule type '{rule_type}'. Available types: {available_types}",
557
+ path,
558
+ {"rule_type": rule_type, "available_types": available_types},
559
+ )
560
+ return
561
+
562
+ # Get params (support both top-level and nested params format)
563
+ params = {}
564
+ nested_params = rule_config.get("params", {})
565
+ if isinstance(nested_params, dict):
566
+ params.update(nested_params)
567
+
568
+ # Also support top-level params (e.g., {"type": "severity", "min_severity": "critical"})
569
+ for key, value in rule_config.items():
570
+ if key not in ("type", "params", "rules", "rule"):
571
+ params[key] = value
572
+
573
+ # Validate required parameters
574
+ param_schema = rule_class.get_param_schema()
575
+ for param_name, param_spec in param_schema.items():
576
+ if param_spec.get("required", False) and param_name not in params:
577
+ result.add_error(
578
+ ValidationErrorType.MISSING_REQUIRED_FIELD,
579
+ f"Missing required parameter '{param_name}' for rule type '{rule_type}'",
580
+ f"{path}.params.{param_name}" if path else f"params.{param_name}",
581
+ {"rule_type": rule_type, "parameter": param_name},
582
+ )
583
+
584
+ def _check_reserved_fields(
585
+ self,
586
+ data: dict[str, Any],
587
+ result: RuleValidationResult,
588
+ config: RuleValidationConfig,
589
+ path: str,
590
+ ) -> None:
591
+ """Check for reserved field names in data.
592
+
593
+ Args:
594
+ data: Dictionary to check.
595
+ result: Validation result to update.
596
+ config: Validation configuration.
597
+ path: Current JSON path.
598
+ """
599
+ for key in data.keys():
600
+ if key.lower() in {rf.lower() for rf in config.reserved_field_names}:
601
+ result.add_error(
602
+ ValidationErrorType.RESERVED_FIELD_NAME,
603
+ f"Reserved field name '{key}' cannot be used",
604
+ f"{path}.{key}" if path else key,
605
+ {"field_name": key},
606
+ )
607
+
608
+ def _generate_rule_id(self, rule_config: dict[str, Any], path: str) -> str:
609
+ """Generate a unique identifier for a rule node.
610
+
611
+ For circular reference detection, we create an ID based on
612
+ the rule's structural properties.
613
+
614
+ Args:
615
+ rule_config: The rule configuration.
616
+ path: The current path (used for position context).
617
+
618
+ Returns:
619
+ A unique identifier string for this rule.
620
+ """
621
+ rule_type = rule_config.get("type", "unknown")
622
+
623
+ # For simple rules, include type and key params
624
+ if rule_type not in self.COMBINATOR_TYPES:
625
+ params = rule_config.get("params", {})
626
+ # Create a deterministic string from key parameters
627
+ param_str = str(sorted(params.items())) if params else ""
628
+ return f"{rule_type}:{param_str}"
629
+
630
+ # For combinators, use type and path to distinguish branches
631
+ return f"{rule_type}@{path}"
632
+
633
+ def _format_cycle_path(self, parent_chain: list[str], current_id: str) -> str:
634
+ """Format a cycle path for error reporting.
635
+
636
+ Args:
637
+ parent_chain: List of parent rule IDs.
638
+ current_id: The current rule ID that creates the cycle.
639
+
640
+ Returns:
641
+ Formatted cycle path string (e.g., "A -> B -> C -> A").
642
+ """
643
+ if current_id in parent_chain:
644
+ # Find where the cycle starts
645
+ cycle_start = parent_chain.index(current_id)
646
+ cycle_nodes = parent_chain[cycle_start:] + [current_id]
647
+ return " -> ".join(cycle_nodes)
648
+ return f"... -> {current_id}"
649
+
650
+
651
+ # Convenience function for quick validation
652
+ def validate_rule_config(
653
+ rule_config: dict[str, Any],
654
+ max_depth: int = 10,
655
+ max_rules_per_combinator: int = 50,
656
+ check_circular_refs: bool = True,
657
+ ) -> RuleValidationResult:
658
+ """Validate a rule configuration with custom settings.
659
+
660
+ This is a convenience function for one-off validations.
661
+ For repeated validations, create a RuleValidator instance.
662
+
663
+ Args:
664
+ rule_config: The rule configuration to validate.
665
+ max_depth: Maximum nesting depth allowed.
666
+ max_rules_per_combinator: Maximum rules in a single combinator.
667
+ check_circular_refs: Whether to check for circular references.
668
+
669
+ Returns:
670
+ RuleValidationResult with validation details.
671
+ """
672
+ config = RuleValidationConfig(
673
+ max_depth=max_depth,
674
+ max_rules_per_combinator=max_rules_per_combinator,
675
+ check_circular_refs=check_circular_refs,
676
+ )
677
+ validator = RuleValidator(config)
678
+ return validator.validate(rule_config)
@@ -464,6 +464,8 @@ class NotificationRuleService:
464
464
  "high_issues",
465
465
  "schedule_failed",
466
466
  "drift_detected",
467
+ "schema_changed",
468
+ "breaking_schema_change",
467
469
  ]
468
470
 
469
471
  def __init__(self, session: AsyncSession) -> None: