nthlayer-workers 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 (175) hide show
  1. nthlayer_workers/__init__.py +5 -0
  2. nthlayer_workers/cli.py +234 -0
  3. nthlayer_workers/correlate/__init__.py +1 -0
  4. nthlayer_workers/correlate/cli.py +847 -0
  5. nthlayer_workers/correlate/config.py +111 -0
  6. nthlayer_workers/correlate/correlation/__init__.py +1 -0
  7. nthlayer_workers/correlate/correlation/changes.py +87 -0
  8. nthlayer_workers/correlate/correlation/dedup.py +62 -0
  9. nthlayer_workers/correlate/correlation/engine.py +244 -0
  10. nthlayer_workers/correlate/correlation/temporal.py +79 -0
  11. nthlayer_workers/correlate/correlation/topology.py +104 -0
  12. nthlayer_workers/correlate/ingestion/__init__.py +1 -0
  13. nthlayer_workers/correlate/ingestion/protocol.py +10 -0
  14. nthlayer_workers/correlate/ingestion/severity.py +18 -0
  15. nthlayer_workers/correlate/ingestion/webhook.py +197 -0
  16. nthlayer_workers/correlate/notifications.py +85 -0
  17. nthlayer_workers/correlate/prometheus.py +234 -0
  18. nthlayer_workers/correlate/reasoning.py +375 -0
  19. nthlayer_workers/correlate/session.py +189 -0
  20. nthlayer_workers/correlate/snapshot/__init__.py +1 -0
  21. nthlayer_workers/correlate/snapshot/generator.py +170 -0
  22. nthlayer_workers/correlate/snapshot/model.py +177 -0
  23. nthlayer_workers/correlate/snapshot/token.py +14 -0
  24. nthlayer_workers/correlate/state.py +88 -0
  25. nthlayer_workers/correlate/store/__init__.py +5 -0
  26. nthlayer_workers/correlate/store/protocol.py +48 -0
  27. nthlayer_workers/correlate/store/sqlite.py +443 -0
  28. nthlayer_workers/correlate/summary.py +180 -0
  29. nthlayer_workers/correlate/traces/__init__.py +1 -0
  30. nthlayer_workers/correlate/traces/protocol.py +120 -0
  31. nthlayer_workers/correlate/traces/tempo.py +667 -0
  32. nthlayer_workers/correlate/traces/topology.py +39 -0
  33. nthlayer_workers/correlate/types.py +77 -0
  34. nthlayer_workers/correlate/worker.py +630 -0
  35. nthlayer_workers/learn/__init__.py +5 -0
  36. nthlayer_workers/learn/__main__.py +5 -0
  37. nthlayer_workers/learn/cli.py +164 -0
  38. nthlayer_workers/learn/retrospective.py +381 -0
  39. nthlayer_workers/learn/trends.py +102 -0
  40. nthlayer_workers/learn/worker.py +366 -0
  41. nthlayer_workers/measure/__init__.py +3 -0
  42. nthlayer_workers/measure/__main__.py +5 -0
  43. nthlayer_workers/measure/_parsing.py +15 -0
  44. nthlayer_workers/measure/adapters/__init__.py +0 -0
  45. nthlayer_workers/measure/adapters/_util.py +24 -0
  46. nthlayer_workers/measure/adapters/devin.py +119 -0
  47. nthlayer_workers/measure/adapters/gastown.py +88 -0
  48. nthlayer_workers/measure/adapters/prometheus.py +277 -0
  49. nthlayer_workers/measure/adapters/protocol.py +20 -0
  50. nthlayer_workers/measure/adapters/webhook.py +161 -0
  51. nthlayer_workers/measure/api/__init__.py +0 -0
  52. nthlayer_workers/measure/api/normalise.py +50 -0
  53. nthlayer_workers/measure/api/queue.py +243 -0
  54. nthlayer_workers/measure/api/response.py +51 -0
  55. nthlayer_workers/measure/api/server.py +504 -0
  56. nthlayer_workers/measure/calibration/__init__.py +0 -0
  57. nthlayer_workers/measure/calibration/loop.py +62 -0
  58. nthlayer_workers/measure/calibration/slos.py +212 -0
  59. nthlayer_workers/measure/calibration/verdict_calibration.py +31 -0
  60. nthlayer_workers/measure/cli.py +753 -0
  61. nthlayer_workers/measure/config.py +191 -0
  62. nthlayer_workers/measure/detection/__init__.py +6 -0
  63. nthlayer_workers/measure/detection/detector.py +82 -0
  64. nthlayer_workers/measure/detection/protocol.py +29 -0
  65. nthlayer_workers/measure/governance/__init__.py +0 -0
  66. nthlayer_workers/measure/governance/engine.py +163 -0
  67. nthlayer_workers/measure/manifest.py +77 -0
  68. nthlayer_workers/measure/notifications.py +53 -0
  69. nthlayer_workers/measure/pipeline/__init__.py +0 -0
  70. nthlayer_workers/measure/pipeline/evaluator.py +155 -0
  71. nthlayer_workers/measure/pipeline/router.py +160 -0
  72. nthlayer_workers/measure/store/__init__.py +0 -0
  73. nthlayer_workers/measure/store/protocol.py +38 -0
  74. nthlayer_workers/measure/store/sqlite.py +276 -0
  75. nthlayer_workers/measure/telemetry.py +116 -0
  76. nthlayer_workers/measure/tiering/__init__.py +0 -0
  77. nthlayer_workers/measure/tiering/classifier.py +58 -0
  78. nthlayer_workers/measure/tiering/promotion.py +118 -0
  79. nthlayer_workers/measure/trends/__init__.py +0 -0
  80. nthlayer_workers/measure/trends/tracker.py +72 -0
  81. nthlayer_workers/measure/types.py +75 -0
  82. nthlayer_workers/measure/worker.py +439 -0
  83. nthlayer_workers/observe/__init__.py +25 -0
  84. nthlayer_workers/observe/__main__.py +5 -0
  85. nthlayer_workers/observe/api/__init__.py +1 -0
  86. nthlayer_workers/observe/assessment.py +95 -0
  87. nthlayer_workers/observe/cli.py +737 -0
  88. nthlayer_workers/observe/config.py +11 -0
  89. nthlayer_workers/observe/db/__init__.py +1 -0
  90. nthlayer_workers/observe/decision_records.py +220 -0
  91. nthlayer_workers/observe/dependencies/__init__.py +18 -0
  92. nthlayer_workers/observe/dependencies/discovery.py +294 -0
  93. nthlayer_workers/observe/dependencies/providers/__init__.py +48 -0
  94. nthlayer_workers/observe/dependencies/providers/backstage.py +467 -0
  95. nthlayer_workers/observe/dependencies/providers/base.py +76 -0
  96. nthlayer_workers/observe/dependencies/providers/consul.py +518 -0
  97. nthlayer_workers/observe/dependencies/providers/etcd.py +360 -0
  98. nthlayer_workers/observe/dependencies/providers/kubernetes.py +682 -0
  99. nthlayer_workers/observe/dependencies/providers/prometheus.py +368 -0
  100. nthlayer_workers/observe/dependencies/providers/zookeeper.py +399 -0
  101. nthlayer_workers/observe/deployments/__init__.py +1 -0
  102. nthlayer_workers/observe/discovery/__init__.py +14 -0
  103. nthlayer_workers/observe/discovery/classifier.py +66 -0
  104. nthlayer_workers/observe/discovery/client.py +189 -0
  105. nthlayer_workers/observe/discovery/models.py +53 -0
  106. nthlayer_workers/observe/drift/__init__.py +26 -0
  107. nthlayer_workers/observe/drift/analyzer.py +383 -0
  108. nthlayer_workers/observe/drift/models.py +174 -0
  109. nthlayer_workers/observe/drift/patterns.py +88 -0
  110. nthlayer_workers/observe/explanation.py +118 -0
  111. nthlayer_workers/observe/gate/__init__.py +39 -0
  112. nthlayer_workers/observe/gate/conditions.py +92 -0
  113. nthlayer_workers/observe/gate/correlator.py +154 -0
  114. nthlayer_workers/observe/gate/evaluator.py +192 -0
  115. nthlayer_workers/observe/gate/policies.py +226 -0
  116. nthlayer_workers/observe/gate_adapter.py +40 -0
  117. nthlayer_workers/observe/incident.py +36 -0
  118. nthlayer_workers/observe/portfolio/__init__.py +17 -0
  119. nthlayer_workers/observe/portfolio/aggregator.py +168 -0
  120. nthlayer_workers/observe/portfolio/scorer.py +13 -0
  121. nthlayer_workers/observe/slo/__init__.py +19 -0
  122. nthlayer_workers/observe/slo/collector.py +235 -0
  123. nthlayer_workers/observe/slo/spec_loader.py +40 -0
  124. nthlayer_workers/observe/sqlite_store.py +152 -0
  125. nthlayer_workers/observe/store.py +92 -0
  126. nthlayer_workers/observe/verification/__init__.py +22 -0
  127. nthlayer_workers/observe/verification/exporter_guidance.py +146 -0
  128. nthlayer_workers/observe/verification/extractor.py +127 -0
  129. nthlayer_workers/observe/verification/models.py +101 -0
  130. nthlayer_workers/observe/verification/verifier.py +111 -0
  131. nthlayer_workers/observe/worker.py +332 -0
  132. nthlayer_workers/respond/__init__.py +2 -0
  133. nthlayer_workers/respond/__main__.py +4 -0
  134. nthlayer_workers/respond/agents/__init__.py +0 -0
  135. nthlayer_workers/respond/agents/base.py +556 -0
  136. nthlayer_workers/respond/agents/communication.py +115 -0
  137. nthlayer_workers/respond/agents/investigation.py +124 -0
  138. nthlayer_workers/respond/agents/remediation.py +219 -0
  139. nthlayer_workers/respond/agents/triage.py +132 -0
  140. nthlayer_workers/respond/cli.py +772 -0
  141. nthlayer_workers/respond/config.py +135 -0
  142. nthlayer_workers/respond/context_store.py +256 -0
  143. nthlayer_workers/respond/coordinator.py +487 -0
  144. nthlayer_workers/respond/metrics.py +104 -0
  145. nthlayer_workers/respond/notification_backends/__init__.py +1 -0
  146. nthlayer_workers/respond/notification_backends/ntfy_backend.py +158 -0
  147. nthlayer_workers/respond/notification_backends/protocol.py +59 -0
  148. nthlayer_workers/respond/notification_backends/slack_backend.py +203 -0
  149. nthlayer_workers/respond/notification_backends/stdout_backend.py +56 -0
  150. nthlayer_workers/respond/notifications.py +247 -0
  151. nthlayer_workers/respond/oncall/__init__.py +1 -0
  152. nthlayer_workers/respond/oncall/escalation.py +103 -0
  153. nthlayer_workers/respond/oncall/runner.py +193 -0
  154. nthlayer_workers/respond/oncall/schedule.py +243 -0
  155. nthlayer_workers/respond/safe_actions/__init__.py +0 -0
  156. nthlayer_workers/respond/safe_actions/actions.py +139 -0
  157. nthlayer_workers/respond/safe_actions/registry.py +171 -0
  158. nthlayer_workers/respond/safe_actions/webhook.py +194 -0
  159. nthlayer_workers/respond/server.py +357 -0
  160. nthlayer_workers/respond/sre/__init__.py +1 -0
  161. nthlayer_workers/respond/sre/brief.py +175 -0
  162. nthlayer_workers/respond/sre/delegation.py +101 -0
  163. nthlayer_workers/respond/sre/post_incident.py +146 -0
  164. nthlayer_workers/respond/sre/shift_report.py +129 -0
  165. nthlayer_workers/respond/sre/suppression.py +91 -0
  166. nthlayer_workers/respond/types.py +109 -0
  167. nthlayer_workers/respond/verdict_submission.py +56 -0
  168. nthlayer_workers/respond/worker.py +533 -0
  169. nthlayer_workers/respond/worker_helpers.py +140 -0
  170. nthlayer_workers/runner.py +198 -0
  171. nthlayer_workers-1.0.0.dist-info/METADATA +19 -0
  172. nthlayer_workers-1.0.0.dist-info/RECORD +175 -0
  173. nthlayer_workers-1.0.0.dist-info/WHEEL +5 -0
  174. nthlayer_workers-1.0.0.dist-info/entry_points.txt +2 -0
  175. nthlayer_workers-1.0.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,439 @@
1
+ """Measure worker module — three-type output from judgment SLO evaluation.
2
+
3
+ Single MeasureModule producing three output types per process_cycle():
4
+ 1. judgment_slo_evaluation assessments (continuous, every cycle)
5
+ 2. quality_breach verdicts (on HEALTHY→BREACH transition only)
6
+ 3. autonomy_change verdicts (on governance reduction, once per breach)
7
+
8
+ State tracks per-SLO status, per-breach autonomy decisions, per-agent
9
+ autonomy levels, and a cursor for time-based polling.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import asyncio
15
+ import uuid
16
+ from dataclasses import dataclass, field
17
+ from datetime import datetime, timedelta, timezone
18
+ from typing import Any
19
+
20
+ import structlog
21
+
22
+ from nthlayer_common.api_client import CoreAPIClient
23
+ from nthlayer_common.providers import PrometheusProvider
24
+
25
+ logger = structlog.get_logger()
26
+
27
+
28
+ @dataclass
29
+ class MeasureModule:
30
+ """Judgment SLO evaluation with breach detection and autonomy governance.
31
+
32
+ Three phases per cycle:
33
+ 1. Evaluate judgment SLOs → judgment_slo_evaluation assessments
34
+ 2. Detect breach transitions → quality_breach verdicts
35
+ 3. Governance on new breaches → autonomy_change verdicts (once per breach)
36
+
37
+ Note: _evaluate_governance is a v1.5 stub (always returns "reduced").
38
+ P3-C.3 replaces it with an Instructor-backed LLM evaluation.
39
+ """
40
+
41
+ client: CoreAPIClient
42
+ prometheus_url: str
43
+ eval_concurrency: int = 5
44
+
45
+ def __post_init__(self):
46
+ self._eval_concurrency = self.eval_concurrency
47
+ self._slo_status: dict[str, str] = {} # slo_key → "healthy"|"breach"
48
+ self._breach_decisions: dict[str, dict] = {} # slo_key → {decided, decided_at, ...}
49
+ self._autonomy_levels: dict[str, str] = {} # service → level
50
+ self._breach_severities: dict[str, str] = {} # slo_key → "low"|"high"|"critical"
51
+
52
+ @property
53
+ def name(self) -> str:
54
+ return "measure"
55
+
56
+ async def restore_state(self, state: dict | None) -> None:
57
+ if not state:
58
+ return
59
+ self._slo_status = state.get("slo_status", {})
60
+ self._breach_decisions = state.get("breach_decisions", {})
61
+ self._autonomy_levels = state.get("autonomy_levels", {})
62
+ self._breach_severities = state.get("breach_severities", {})
63
+
64
+ async def process_cycle(self) -> None:
65
+ # Fetch manifests with judgment SLOs
66
+ result = await self.client.get_manifests()
67
+ if not result.ok or not result.data:
68
+ return
69
+
70
+ manifests = result.data
71
+ provider = PrometheusProvider(self.prometheus_url)
72
+
73
+ try:
74
+ current_status: dict[str, str] = {}
75
+ evaluation_ids: dict[str, str] = {} # slo_key → assessment_id
76
+
77
+ # Phase 1: Evaluate each judgment SLO (parallel with concurrency cap)
78
+ slos_to_eval = []
79
+ for m in manifests:
80
+ service = m.get("name")
81
+ if not service:
82
+ continue
83
+ for slo in m.get("slos", []):
84
+ if not slo.get("judgment_type"):
85
+ continue
86
+ slos_to_eval.append((service, slo))
87
+
88
+ sem = asyncio.Semaphore(self._eval_concurrency)
89
+
90
+ async def eval_one(svc: str, slo_data: dict) -> None:
91
+ async with sem:
92
+ try:
93
+ await self._evaluate_slo(
94
+ svc, slo_data, provider, current_status, evaluation_ids
95
+ )
96
+ except Exception:
97
+ logger.warning("measure_eval_failed", service=svc,
98
+ slo=slo_data.get("name"))
99
+
100
+ await asyncio.gather(
101
+ *(eval_one(svc, slo) for svc, slo in slos_to_eval),
102
+ return_exceptions=True,
103
+ )
104
+
105
+ # Phase 2: Detect breach transitions + classify severity
106
+ new_breaches = _detect_transitions(current_status, self._slo_status)
107
+ breach_verdict_ids: dict[str, str] = {}
108
+ for slo_key in new_breaches:
109
+ parts = slo_key.split(":", 1)
110
+ service = parts[0] if parts else "unknown"
111
+ slo_name = parts[1] if len(parts) > 1 else "unknown"
112
+
113
+ # Find SLO data for severity classification
114
+ slo_data = self._find_slo(manifests, service, slo_name)
115
+ target = slo_data.get("target", 0) if slo_data else 0
116
+ judgment_type = slo_data.get("judgment_type") if slo_data else None
117
+ current_value = evaluation_ids.get(f"__val__{slo_key}", 0)
118
+
119
+ severity = classify_severity(judgment_type, target, current_value)
120
+ self._breach_severities[slo_key] = severity
121
+
122
+ breach_id = f"vrd-breach-{service}-{slo_name}-{uuid.uuid4().hex[:8]}"
123
+ breach_verdict_ids[slo_key] = breach_id
124
+ breach_verdict = {
125
+ "id": breach_id,
126
+ "created_at": datetime.now(timezone.utc).isoformat(),
127
+ "type": "quality_breach",
128
+ "service": service,
129
+ "parent_ids": [evaluation_ids.get(slo_key, "")],
130
+ "data": {
131
+ "slo_name": slo_name,
132
+ "slo_key": slo_key,
133
+ "severity": severity,
134
+ "judgment_type": judgment_type,
135
+ "target": target,
136
+ "current_value": current_value,
137
+ },
138
+ }
139
+ await self.client.submit_verdict(breach_verdict)
140
+
141
+ # Phase 3: Governance — severity-based, fully deterministic (no LLM in v1.5)
142
+ # Process per-service: count breaching SLOs, determine action
143
+ breaching_services: dict[str, list[str]] = {}
144
+ for key, status in current_status.items():
145
+ if status == "breach":
146
+ svc = key.split(":", 1)[0]
147
+ breaching_services.setdefault(svc, []).append(key)
148
+
149
+ for service, breaching_keys in breaching_services.items():
150
+ # Only act on services with new breaches this cycle
151
+ if not any(k in new_breaches for k in breaching_keys):
152
+ continue
153
+ # Skip if all breaches already have governance decisions
154
+ undecided = [
155
+ k for k in breaching_keys
156
+ if not self._breach_decisions.get(k, {}).get("decided")
157
+ ]
158
+ if not undecided:
159
+ continue
160
+
161
+ action, steps = self._compute_governance(service, breaching_keys)
162
+
163
+ now = datetime.now(timezone.utc)
164
+ for k in undecided:
165
+ self._breach_decisions[k] = {
166
+ "decided": True,
167
+ "decided_at": now.isoformat(),
168
+ "autonomy_action": action,
169
+ }
170
+
171
+ if action == "reduced":
172
+ current_level = self._autonomy_levels.get(service, "fully_autonomous")
173
+ new_level = _reduce_autonomy(current_level, steps)
174
+ self._autonomy_levels[service] = new_level
175
+
176
+ verdict = {
177
+ "id": f"vrd-auto-{service}-{uuid.uuid4().hex[:8]}",
178
+ "created_at": now.isoformat(),
179
+ "type": "autonomy_change",
180
+ "service": service,
181
+ "parent_ids": [breach_verdict_ids.get(k, "") for k in undecided if k in breach_verdict_ids],
182
+ "data": {
183
+ "previous_level": current_level,
184
+ "new_level": new_level,
185
+ "breaching_slo_count": len(breaching_keys),
186
+ "reason": f"Autonomy reduced: {len(breaching_keys)} SLO(s) breaching",
187
+ },
188
+ }
189
+ await self.client.submit_verdict(verdict)
190
+
191
+ # Clear breach state for recovered SLOs.
192
+ # Intentionally does NOT restore _autonomy_levels — ratchet is one-way.
193
+ for slo_key in list(self._breach_decisions):
194
+ if current_status.get(slo_key) == "healthy":
195
+ del self._breach_decisions[slo_key]
196
+ self._breach_severities.pop(slo_key, None)
197
+
198
+ # Carry forward status for SLOs that failed to evaluate this cycle
199
+ # (Prometheus transient failure). Prevents false transitions on recovery.
200
+ for key, status in self._slo_status.items():
201
+ if key not in current_status:
202
+ current_status[key] = status
203
+
204
+ # Update state
205
+ self._slo_status = current_status
206
+
207
+ finally:
208
+ await provider.aclose()
209
+
210
+ async def _evaluate_slo(
211
+ self,
212
+ service: str,
213
+ slo: dict,
214
+ provider: PrometheusProvider,
215
+ current_status: dict[str, str],
216
+ evaluation_ids: dict[str, str],
217
+ ) -> None:
218
+ """Evaluate a single judgment SLO and submit assessment."""
219
+ slo_name = slo.get("name", "unknown")
220
+ slo_key = f"{service}:{slo_name}"
221
+ target = slo.get("target", 0)
222
+ indicator_query = slo.get("indicator_query")
223
+
224
+ if not indicator_query:
225
+ return
226
+
227
+ try:
228
+ current_value = await provider.get_sli_value(indicator_query)
229
+ except Exception:
230
+ logger.debug("measure_prometheus_failed", service=service, slo=slo_name)
231
+ return
232
+
233
+ # Determine breach: for judgment SLOs, SLI >= target means healthy
234
+ # (inverted from classical: reversal_rate SLI is 1 - reversal_rate,
235
+ # so SLI >= target means rate is below threshold)
236
+ status = "healthy" if current_value >= target else "breach"
237
+ current_status[slo_key] = status
238
+
239
+ now = datetime.now(timezone.utc)
240
+ assessment_id = f"jse-{service}-{slo_name}-{uuid.uuid4().hex[:8]}"
241
+ evaluation_ids[slo_key] = assessment_id
242
+ evaluation_ids[f"__val__{slo_key}"] = current_value # for severity classification
243
+
244
+ assessment = {
245
+ "id": assessment_id,
246
+ "created_at": now.isoformat(),
247
+ "kind": "judgment_slo_evaluation",
248
+ "service": service,
249
+ "data": {
250
+ "slo_name": slo_name,
251
+ "slo_type": slo.get("judgment_type"),
252
+ "target": target,
253
+ "current_value": round(current_value, 6),
254
+ "status": status,
255
+ "window": slo.get("window", "30d"),
256
+ },
257
+ }
258
+ result = await self.client.submit_assessment(assessment)
259
+ if not result.ok:
260
+ logger.warning("measure_assessment_submit_failed",
261
+ service=service, slo=slo_name)
262
+
263
+ def _compute_governance(self, service: str, breaching_keys: list[str]) -> tuple[str, int]:
264
+ """Compute governance action based on breach count and severity.
265
+
266
+ Fully deterministic in v1.5 — no LLM calls. v2 may add LLM reasoning
267
+ for borderline cases. v1.5's conservative rules may produce unnecessary
268
+ reductions; manual elevation handles those.
269
+
270
+ Returns (action, steps):
271
+ Positive steps = relative drop (move N positions toward observer).
272
+ Negative steps = absolute floor (-1 = observer, -2 = advisor).
273
+ """
274
+ if not breaching_keys:
275
+ return "no_change", 0
276
+
277
+ if len(breaching_keys) > 1:
278
+ # Multiple simultaneous breaches
279
+ any_critical = any(
280
+ self._breach_severities.get(k) == "critical"
281
+ for k in breaching_keys
282
+ )
283
+ if any_critical:
284
+ return "reduced", -1 # floor=observer (idx 4, most restrictive)
285
+ return "reduced", -2 # floor=advisor (idx 3, less restrictive than observer)
286
+
287
+ # Single breach — severity determines drop
288
+ severity = self._breach_severities.get(breaching_keys[0], "high")
289
+ if severity == "low":
290
+ return "reduced", 1
291
+ if severity == "high":
292
+ return "reduced", 2
293
+ if severity == "critical":
294
+ return "reduced", -1 # drop to observer
295
+ return "reduced", 1 # fallback
296
+
297
+ @staticmethod
298
+ def _find_slo(manifests: list[dict], service: str, slo_name: str) -> dict | None:
299
+ """Find SLO data from manifests for severity classification."""
300
+ for m in manifests:
301
+ if m.get("name") != service:
302
+ continue
303
+ for slo in m.get("slos", []):
304
+ if slo.get("name") == slo_name:
305
+ return slo
306
+ return None
307
+
308
+ async def get_state(self) -> dict:
309
+ return {
310
+ "slo_status": self._slo_status,
311
+ "breach_decisions": self._breach_decisions,
312
+ "autonomy_levels": self._autonomy_levels,
313
+ "breach_severities": self._breach_severities,
314
+ }
315
+
316
+
317
+ # ---------------------------------------------------------------------------
318
+ # Helpers
319
+ # ---------------------------------------------------------------------------
320
+
321
+
322
+ def _detect_transitions(
323
+ current: dict[str, str],
324
+ previous: dict[str, str],
325
+ ) -> list[str]:
326
+ """Return SLO keys that transitioned HEALTHY→BREACH."""
327
+ transitions = []
328
+ for key, status in current.items():
329
+ prev = previous.get(key, "unknown")
330
+ if status == "breach" and prev != "breach":
331
+ transitions.append(key)
332
+ return transitions
333
+
334
+
335
+ # Ordered least→most restrictive. "observer" is the floor — agent can only watch.
336
+ AUTONOMY_LADDER = [
337
+ "fully_autonomous", "autonomous", "limited_autonomous", "advisor", "observer"
338
+ ]
339
+
340
+
341
+ def _reduce_autonomy(current_level: str, steps: int = 1) -> str:
342
+ """Reduce autonomy by steps. One-way ratchet — never promotes upward.
343
+
344
+ Positive steps = relative drop (move N positions toward observer).
345
+ Negative steps = absolute floor (-1 = observer, -2 = advisor),
346
+ but clamped so we never promote above current level.
347
+ """
348
+ # Unknown level: fail safe to advisor (conservative low-trust default).
349
+ # If v2 renames a level or a deployment uses custom names, unrecognised
350
+ # agents default to low-trust rather than full autonomy.
351
+ if current_level not in AUTONOMY_LADDER:
352
+ return "advisor"
353
+
354
+ current_idx = AUTONOMY_LADDER.index(current_level)
355
+
356
+ if steps == -1:
357
+ target_idx = AUTONOMY_LADDER.index("observer")
358
+ elif steps == -2:
359
+ target_idx = AUTONOMY_LADDER.index("advisor")
360
+ else:
361
+ target_idx = min(current_idx + steps, len(AUTONOMY_LADDER) - 1)
362
+
363
+ # One-way ratchet: never promote (target must be >= current)
364
+ final_idx = max(current_idx, target_idx)
365
+ return AUTONOMY_LADDER[final_idx]
366
+
367
+
368
+ # Budget-consumption severity types (use error budget ratio)
369
+ _BUDGET_CONSUMPTION_TYPES = frozenset({
370
+ "reversal_rate", "high_confidence_failure", "escalation",
371
+ "outcomes", "audit_sampling",
372
+ })
373
+
374
+
375
+ def classify_severity(
376
+ judgment_type: str | None,
377
+ target: float,
378
+ current_value: float,
379
+ ) -> str:
380
+ """Classify breach severity per judgment SLO type.
381
+
382
+ Returns 'low', 'high', or 'critical'. Fully deterministic.
383
+ """
384
+ if judgment_type in _BUDGET_CONSUMPTION_TYPES:
385
+ return _classify_budget_consumption(target, current_value)
386
+ if judgment_type == "stability":
387
+ return "high" # any stability breach is high; sustained tracking is v2
388
+ if judgment_type == "segments":
389
+ return _classify_variance(target, current_value)
390
+ if judgment_type == "calibration":
391
+ return _classify_calibration(target, current_value)
392
+ # Unknown type → "high" for safety
393
+ return "high"
394
+
395
+
396
+ def _classify_budget_consumption(target: float, current_value: float) -> str:
397
+ """Classify by error budget consumption percentage.
398
+
399
+ budget_consumption = (target - value) / (1 - target) × 100
400
+ <200% = low, 200-500% = high, >500% = critical
401
+ """
402
+ budget = 1.0 - target
403
+ if budget <= 0:
404
+ return "critical" # target=1.0 means zero budget, any miss is critical
405
+ consumption = (target - current_value) / budget * 100
406
+ if consumption < 200:
407
+ return "low"
408
+ if consumption < 500:
409
+ return "high"
410
+ return "critical"
411
+
412
+
413
+ def _classify_variance(target: float, current_value: float) -> str:
414
+ """Classify segments breach by variance ratio.
415
+
416
+ Precondition: current_value < target (caller verified breach).
417
+ <2x = low, 2-5x = high, >5x = critical
418
+ """
419
+ if target <= 0:
420
+ return "high"
421
+ ratio = (target - current_value) / target # positive when breaching
422
+ if ratio < 2:
423
+ return "low"
424
+ if ratio < 5:
425
+ return "high"
426
+ return "critical"
427
+
428
+
429
+ def _classify_calibration(target: float, current_value: float) -> str:
430
+ """Classify calibration breach by delta.
431
+
432
+ <0.1 = low, 0.1-0.3 = high, >0.3 = critical
433
+ """
434
+ delta = abs(current_value - target)
435
+ if delta < 0.1:
436
+ return "low"
437
+ if delta < 0.3:
438
+ return "high"
439
+ return "critical"
@@ -0,0 +1,25 @@
1
+ """nthlayer-observe — deterministic runtime infrastructure layer."""
2
+
3
+ from nthlayer_workers.observe.assessment import (
4
+ VALID_ASSESSMENT_TYPES,
5
+ Assessment,
6
+ create,
7
+ from_dict,
8
+ to_dict,
9
+ )
10
+ from nthlayer_workers.observe.sqlite_store import SQLiteAssessmentStore
11
+ from nthlayer_workers.observe.store import AssessmentFilter, AssessmentStore, MemoryAssessmentStore
12
+
13
+ __all__ = [
14
+ "Assessment",
15
+ "AssessmentFilter",
16
+ "AssessmentStore",
17
+ "MemoryAssessmentStore",
18
+ "SQLiteAssessmentStore",
19
+ "VALID_ASSESSMENT_TYPES",
20
+ "create",
21
+ "from_dict",
22
+ "to_dict",
23
+ ]
24
+
25
+ __version__ = "0.1.0"
@@ -0,0 +1,5 @@
1
+ """Allow running as python -m nthlayer_observe."""
2
+
3
+ from nthlayer_workers.observe.cli import main
4
+
5
+ raise SystemExit(main())
@@ -0,0 +1 @@
1
+ """Runtime HTTP API server."""
@@ -0,0 +1,95 @@
1
+ """Assessment dataclass — a deterministic observation of system state."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import threading
6
+ import uuid
7
+ from dataclasses import dataclass
8
+ from datetime import datetime, timezone
9
+
10
+ from nthlayer_common.cloudevents import ASSESSMENT_KINDS
11
+
12
+ # Canonical kinds from common + observe-internal types not in CloudEvents
13
+ # taxonomy (verification is CLI-only, never submitted to core).
14
+ VALID_ASSESSMENT_TYPES = ASSESSMENT_KINDS | frozenset({"verification"})
15
+
16
+ _id_lock = threading.Lock()
17
+ _id_sequence = 0
18
+
19
+
20
+ def _generate_id() -> str:
21
+ """Generate a unique assessment ID. Thread-safe."""
22
+ global _id_sequence
23
+ with _id_lock:
24
+ _id_sequence += 1
25
+ seq = _id_sequence
26
+ short_uuid = uuid.uuid4().hex[:8]
27
+ now = datetime.now(timezone.utc)
28
+ date_part = now.strftime("%Y-%m-%d")
29
+ return f"asm-{date_part}-{short_uuid}-{seq:05d}"
30
+
31
+
32
+ @dataclass
33
+ class Assessment:
34
+ """A deterministic observation of system state.
35
+
36
+ NOT a verdict. No judgment, no confidence score, no LLM reasoning.
37
+ Same inputs always produce the same assessment.
38
+ """
39
+
40
+ id: str
41
+ created_at: datetime
42
+ kind: str
43
+ service: str
44
+ producer: str
45
+ data: dict
46
+
47
+
48
+ def create(
49
+ kind: str,
50
+ service: str,
51
+ data: dict,
52
+ *,
53
+ producer: str = "nthlayer-observe",
54
+ ) -> Assessment:
55
+ """Create a new Assessment with generated ID and UTC timestamp."""
56
+ if kind not in VALID_ASSESSMENT_TYPES:
57
+ raise ValueError(
58
+ f"Invalid kind {kind!r}, "
59
+ f"must be one of {sorted(VALID_ASSESSMENT_TYPES)}"
60
+ )
61
+ return Assessment(
62
+ id=_generate_id(),
63
+ created_at=datetime.now(timezone.utc),
64
+ kind=kind,
65
+ service=service,
66
+ producer=producer,
67
+ data=data,
68
+ )
69
+
70
+
71
+ def to_dict(assessment: Assessment) -> dict:
72
+ """Serialize an Assessment to a plain dict."""
73
+ return {
74
+ "id": assessment.id,
75
+ "created_at": assessment.created_at.isoformat(),
76
+ "kind": assessment.kind,
77
+ "service": assessment.service,
78
+ "producer": assessment.producer,
79
+ "data": assessment.data,
80
+ }
81
+
82
+
83
+ def from_dict(raw: dict) -> Assessment:
84
+ """Deserialize an Assessment from a plain dict."""
85
+ ts = raw["created_at"]
86
+ if isinstance(ts, str):
87
+ ts = datetime.fromisoformat(ts)
88
+ return Assessment(
89
+ id=raw["id"],
90
+ created_at=ts,
91
+ kind=raw["kind"],
92
+ service=raw["service"],
93
+ producer=raw["producer"],
94
+ data=raw["data"],
95
+ )