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.
- nthlayer_workers/__init__.py +5 -0
- nthlayer_workers/cli.py +234 -0
- nthlayer_workers/correlate/__init__.py +1 -0
- nthlayer_workers/correlate/cli.py +847 -0
- nthlayer_workers/correlate/config.py +111 -0
- nthlayer_workers/correlate/correlation/__init__.py +1 -0
- nthlayer_workers/correlate/correlation/changes.py +87 -0
- nthlayer_workers/correlate/correlation/dedup.py +62 -0
- nthlayer_workers/correlate/correlation/engine.py +244 -0
- nthlayer_workers/correlate/correlation/temporal.py +79 -0
- nthlayer_workers/correlate/correlation/topology.py +104 -0
- nthlayer_workers/correlate/ingestion/__init__.py +1 -0
- nthlayer_workers/correlate/ingestion/protocol.py +10 -0
- nthlayer_workers/correlate/ingestion/severity.py +18 -0
- nthlayer_workers/correlate/ingestion/webhook.py +197 -0
- nthlayer_workers/correlate/notifications.py +85 -0
- nthlayer_workers/correlate/prometheus.py +234 -0
- nthlayer_workers/correlate/reasoning.py +375 -0
- nthlayer_workers/correlate/session.py +189 -0
- nthlayer_workers/correlate/snapshot/__init__.py +1 -0
- nthlayer_workers/correlate/snapshot/generator.py +170 -0
- nthlayer_workers/correlate/snapshot/model.py +177 -0
- nthlayer_workers/correlate/snapshot/token.py +14 -0
- nthlayer_workers/correlate/state.py +88 -0
- nthlayer_workers/correlate/store/__init__.py +5 -0
- nthlayer_workers/correlate/store/protocol.py +48 -0
- nthlayer_workers/correlate/store/sqlite.py +443 -0
- nthlayer_workers/correlate/summary.py +180 -0
- nthlayer_workers/correlate/traces/__init__.py +1 -0
- nthlayer_workers/correlate/traces/protocol.py +120 -0
- nthlayer_workers/correlate/traces/tempo.py +667 -0
- nthlayer_workers/correlate/traces/topology.py +39 -0
- nthlayer_workers/correlate/types.py +77 -0
- nthlayer_workers/correlate/worker.py +630 -0
- nthlayer_workers/learn/__init__.py +5 -0
- nthlayer_workers/learn/__main__.py +5 -0
- nthlayer_workers/learn/cli.py +164 -0
- nthlayer_workers/learn/retrospective.py +381 -0
- nthlayer_workers/learn/trends.py +102 -0
- nthlayer_workers/learn/worker.py +366 -0
- nthlayer_workers/measure/__init__.py +3 -0
- nthlayer_workers/measure/__main__.py +5 -0
- nthlayer_workers/measure/_parsing.py +15 -0
- nthlayer_workers/measure/adapters/__init__.py +0 -0
- nthlayer_workers/measure/adapters/_util.py +24 -0
- nthlayer_workers/measure/adapters/devin.py +119 -0
- nthlayer_workers/measure/adapters/gastown.py +88 -0
- nthlayer_workers/measure/adapters/prometheus.py +277 -0
- nthlayer_workers/measure/adapters/protocol.py +20 -0
- nthlayer_workers/measure/adapters/webhook.py +161 -0
- nthlayer_workers/measure/api/__init__.py +0 -0
- nthlayer_workers/measure/api/normalise.py +50 -0
- nthlayer_workers/measure/api/queue.py +243 -0
- nthlayer_workers/measure/api/response.py +51 -0
- nthlayer_workers/measure/api/server.py +504 -0
- nthlayer_workers/measure/calibration/__init__.py +0 -0
- nthlayer_workers/measure/calibration/loop.py +62 -0
- nthlayer_workers/measure/calibration/slos.py +212 -0
- nthlayer_workers/measure/calibration/verdict_calibration.py +31 -0
- nthlayer_workers/measure/cli.py +753 -0
- nthlayer_workers/measure/config.py +191 -0
- nthlayer_workers/measure/detection/__init__.py +6 -0
- nthlayer_workers/measure/detection/detector.py +82 -0
- nthlayer_workers/measure/detection/protocol.py +29 -0
- nthlayer_workers/measure/governance/__init__.py +0 -0
- nthlayer_workers/measure/governance/engine.py +163 -0
- nthlayer_workers/measure/manifest.py +77 -0
- nthlayer_workers/measure/notifications.py +53 -0
- nthlayer_workers/measure/pipeline/__init__.py +0 -0
- nthlayer_workers/measure/pipeline/evaluator.py +155 -0
- nthlayer_workers/measure/pipeline/router.py +160 -0
- nthlayer_workers/measure/store/__init__.py +0 -0
- nthlayer_workers/measure/store/protocol.py +38 -0
- nthlayer_workers/measure/store/sqlite.py +276 -0
- nthlayer_workers/measure/telemetry.py +116 -0
- nthlayer_workers/measure/tiering/__init__.py +0 -0
- nthlayer_workers/measure/tiering/classifier.py +58 -0
- nthlayer_workers/measure/tiering/promotion.py +118 -0
- nthlayer_workers/measure/trends/__init__.py +0 -0
- nthlayer_workers/measure/trends/tracker.py +72 -0
- nthlayer_workers/measure/types.py +75 -0
- nthlayer_workers/measure/worker.py +439 -0
- nthlayer_workers/observe/__init__.py +25 -0
- nthlayer_workers/observe/__main__.py +5 -0
- nthlayer_workers/observe/api/__init__.py +1 -0
- nthlayer_workers/observe/assessment.py +95 -0
- nthlayer_workers/observe/cli.py +737 -0
- nthlayer_workers/observe/config.py +11 -0
- nthlayer_workers/observe/db/__init__.py +1 -0
- nthlayer_workers/observe/decision_records.py +220 -0
- nthlayer_workers/observe/dependencies/__init__.py +18 -0
- nthlayer_workers/observe/dependencies/discovery.py +294 -0
- nthlayer_workers/observe/dependencies/providers/__init__.py +48 -0
- nthlayer_workers/observe/dependencies/providers/backstage.py +467 -0
- nthlayer_workers/observe/dependencies/providers/base.py +76 -0
- nthlayer_workers/observe/dependencies/providers/consul.py +518 -0
- nthlayer_workers/observe/dependencies/providers/etcd.py +360 -0
- nthlayer_workers/observe/dependencies/providers/kubernetes.py +682 -0
- nthlayer_workers/observe/dependencies/providers/prometheus.py +368 -0
- nthlayer_workers/observe/dependencies/providers/zookeeper.py +399 -0
- nthlayer_workers/observe/deployments/__init__.py +1 -0
- nthlayer_workers/observe/discovery/__init__.py +14 -0
- nthlayer_workers/observe/discovery/classifier.py +66 -0
- nthlayer_workers/observe/discovery/client.py +189 -0
- nthlayer_workers/observe/discovery/models.py +53 -0
- nthlayer_workers/observe/drift/__init__.py +26 -0
- nthlayer_workers/observe/drift/analyzer.py +383 -0
- nthlayer_workers/observe/drift/models.py +174 -0
- nthlayer_workers/observe/drift/patterns.py +88 -0
- nthlayer_workers/observe/explanation.py +118 -0
- nthlayer_workers/observe/gate/__init__.py +39 -0
- nthlayer_workers/observe/gate/conditions.py +92 -0
- nthlayer_workers/observe/gate/correlator.py +154 -0
- nthlayer_workers/observe/gate/evaluator.py +192 -0
- nthlayer_workers/observe/gate/policies.py +226 -0
- nthlayer_workers/observe/gate_adapter.py +40 -0
- nthlayer_workers/observe/incident.py +36 -0
- nthlayer_workers/observe/portfolio/__init__.py +17 -0
- nthlayer_workers/observe/portfolio/aggregator.py +168 -0
- nthlayer_workers/observe/portfolio/scorer.py +13 -0
- nthlayer_workers/observe/slo/__init__.py +19 -0
- nthlayer_workers/observe/slo/collector.py +235 -0
- nthlayer_workers/observe/slo/spec_loader.py +40 -0
- nthlayer_workers/observe/sqlite_store.py +152 -0
- nthlayer_workers/observe/store.py +92 -0
- nthlayer_workers/observe/verification/__init__.py +22 -0
- nthlayer_workers/observe/verification/exporter_guidance.py +146 -0
- nthlayer_workers/observe/verification/extractor.py +127 -0
- nthlayer_workers/observe/verification/models.py +101 -0
- nthlayer_workers/observe/verification/verifier.py +111 -0
- nthlayer_workers/observe/worker.py +332 -0
- nthlayer_workers/respond/__init__.py +2 -0
- nthlayer_workers/respond/__main__.py +4 -0
- nthlayer_workers/respond/agents/__init__.py +0 -0
- nthlayer_workers/respond/agents/base.py +556 -0
- nthlayer_workers/respond/agents/communication.py +115 -0
- nthlayer_workers/respond/agents/investigation.py +124 -0
- nthlayer_workers/respond/agents/remediation.py +219 -0
- nthlayer_workers/respond/agents/triage.py +132 -0
- nthlayer_workers/respond/cli.py +772 -0
- nthlayer_workers/respond/config.py +135 -0
- nthlayer_workers/respond/context_store.py +256 -0
- nthlayer_workers/respond/coordinator.py +487 -0
- nthlayer_workers/respond/metrics.py +104 -0
- nthlayer_workers/respond/notification_backends/__init__.py +1 -0
- nthlayer_workers/respond/notification_backends/ntfy_backend.py +158 -0
- nthlayer_workers/respond/notification_backends/protocol.py +59 -0
- nthlayer_workers/respond/notification_backends/slack_backend.py +203 -0
- nthlayer_workers/respond/notification_backends/stdout_backend.py +56 -0
- nthlayer_workers/respond/notifications.py +247 -0
- nthlayer_workers/respond/oncall/__init__.py +1 -0
- nthlayer_workers/respond/oncall/escalation.py +103 -0
- nthlayer_workers/respond/oncall/runner.py +193 -0
- nthlayer_workers/respond/oncall/schedule.py +243 -0
- nthlayer_workers/respond/safe_actions/__init__.py +0 -0
- nthlayer_workers/respond/safe_actions/actions.py +139 -0
- nthlayer_workers/respond/safe_actions/registry.py +171 -0
- nthlayer_workers/respond/safe_actions/webhook.py +194 -0
- nthlayer_workers/respond/server.py +357 -0
- nthlayer_workers/respond/sre/__init__.py +1 -0
- nthlayer_workers/respond/sre/brief.py +175 -0
- nthlayer_workers/respond/sre/delegation.py +101 -0
- nthlayer_workers/respond/sre/post_incident.py +146 -0
- nthlayer_workers/respond/sre/shift_report.py +129 -0
- nthlayer_workers/respond/sre/suppression.py +91 -0
- nthlayer_workers/respond/types.py +109 -0
- nthlayer_workers/respond/verdict_submission.py +56 -0
- nthlayer_workers/respond/worker.py +533 -0
- nthlayer_workers/respond/worker_helpers.py +140 -0
- nthlayer_workers/runner.py +198 -0
- nthlayer_workers-1.0.0.dist-info/METADATA +19 -0
- nthlayer_workers-1.0.0.dist-info/RECORD +175 -0
- nthlayer_workers-1.0.0.dist-info/WHEEL +5 -0
- nthlayer_workers-1.0.0.dist-info/entry_points.txt +2 -0
- 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 @@
|
|
|
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
|
+
)
|