nthlayer-common 2.1.2__tar.gz → 3.0.0__tar.gz

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 (127) hide show
  1. {nthlayer_common-2.1.2 → nthlayer_common-3.0.0}/PKG-INFO +1 -1
  2. {nthlayer_common-2.1.2 → nthlayer_common-3.0.0}/pyproject.toml +1 -1
  3. {nthlayer_common-2.1.2 → nthlayer_common-3.0.0}/src/nthlayer_common/manifest/__init__.py +8 -0
  4. {nthlayer_common-2.1.2 → nthlayer_common-3.0.0}/src/nthlayer_common/manifest/openslo/parser.py +77 -4
  5. {nthlayer_common-2.1.2 → nthlayer_common-3.0.0}/src/nthlayer_common/manifest/parser/v1.py +23 -1
  6. {nthlayer_common-2.1.2 → nthlayer_common-3.0.0}/src/nthlayer_common/manifest/parser/v2.py +25 -20
  7. nthlayer_common-3.0.0/src/nthlayer_common/manifest/scan.py +375 -0
  8. nthlayer_common-3.0.0/src/nthlayer_common/manifest/target_validation.py +362 -0
  9. {nthlayer_common-2.1.2 → nthlayer_common-3.0.0}/src/nthlayer_common/manifest/v1_compat.py +66 -34
  10. {nthlayer_common-2.1.2 → nthlayer_common-3.0.0}/src/nthlayer_common.egg-info/PKG-INFO +1 -1
  11. {nthlayer_common-2.1.2 → nthlayer_common-3.0.0}/src/nthlayer_common.egg-info/SOURCES.txt +2 -0
  12. nthlayer_common-3.0.0/tests/test_judgment_targets_against_spec_examples.py +167 -0
  13. {nthlayer_common-2.1.2 → nthlayer_common-3.0.0}/tests/test_manifest_models.py +16 -2
  14. {nthlayer_common-2.1.2 → nthlayer_common-3.0.0}/tests/test_manifest_parser.py +128 -24
  15. nthlayer_common-3.0.0/tests/test_manifest_scan.py +862 -0
  16. {nthlayer_common-2.1.2 → nthlayer_common-3.0.0}/tests/test_manifest_service_type.py +2 -2
  17. nthlayer_common-3.0.0/tests/test_target_unit_equivalence.py +839 -0
  18. {nthlayer_common-2.1.2 → nthlayer_common-3.0.0}/tests/test_v1_to_v2_migration.py +28 -1
  19. nthlayer_common-2.1.2/src/nthlayer_common/manifest/scan.py +0 -177
  20. nthlayer_common-2.1.2/src/nthlayer_common/manifest/target_validation.py +0 -78
  21. nthlayer_common-2.1.2/tests/test_manifest_scan.py +0 -301
  22. {nthlayer_common-2.1.2 → nthlayer_common-3.0.0}/README.md +0 -0
  23. {nthlayer_common-2.1.2 → nthlayer_common-3.0.0}/setup.cfg +0 -0
  24. {nthlayer_common-2.1.2 → nthlayer_common-3.0.0}/src/nthlayer_common/__init__.py +0 -0
  25. {nthlayer_common-2.1.2 → nthlayer_common-3.0.0}/src/nthlayer_common/api_client.py +0 -0
  26. {nthlayer_common-2.1.2 → nthlayer_common-3.0.0}/src/nthlayer_common/clients/__init__.py +0 -0
  27. {nthlayer_common-2.1.2 → nthlayer_common-3.0.0}/src/nthlayer_common/clients/base.py +0 -0
  28. {nthlayer_common-2.1.2 → nthlayer_common-3.0.0}/src/nthlayer_common/clients/cortex.py +0 -0
  29. {nthlayer_common-2.1.2 → nthlayer_common-3.0.0}/src/nthlayer_common/clients/mimir.py +0 -0
  30. {nthlayer_common-2.1.2 → nthlayer_common-3.0.0}/src/nthlayer_common/clients/pagerduty.py +0 -0
  31. {nthlayer_common-2.1.2 → nthlayer_common-3.0.0}/src/nthlayer_common/clients/slack.py +0 -0
  32. {nthlayer_common-2.1.2 → nthlayer_common-3.0.0}/src/nthlayer_common/cloudevents.py +0 -0
  33. {nthlayer_common-2.1.2 → nthlayer_common-3.0.0}/src/nthlayer_common/config.py +0 -0
  34. {nthlayer_common-2.1.2 → nthlayer_common-3.0.0}/src/nthlayer_common/dependency_models.py +0 -0
  35. {nthlayer_common-2.1.2 → nthlayer_common-3.0.0}/src/nthlayer_common/domain_models.py +0 -0
  36. {nthlayer_common-2.1.2 → nthlayer_common-3.0.0}/src/nthlayer_common/errors.py +0 -0
  37. {nthlayer_common-2.1.2 → nthlayer_common-3.0.0}/src/nthlayer_common/explanation.py +0 -0
  38. {nthlayer_common-2.1.2 → nthlayer_common-3.0.0}/src/nthlayer_common/gate_models.py +0 -0
  39. {nthlayer_common-2.1.2 → nthlayer_common-3.0.0}/src/nthlayer_common/governance_bridge/__init__.py +0 -0
  40. {nthlayer_common-2.1.2 → nthlayer_common-3.0.0}/src/nthlayer_common/governance_bridge/emitter.py +0 -0
  41. {nthlayer_common-2.1.2 → nthlayer_common-3.0.0}/src/nthlayer_common/governance_bridge/models.py +0 -0
  42. {nthlayer_common-2.1.2 → nthlayer_common-3.0.0}/src/nthlayer_common/identity/__init__.py +0 -0
  43. {nthlayer_common-2.1.2 → nthlayer_common-3.0.0}/src/nthlayer_common/identity/models.py +0 -0
  44. {nthlayer_common-2.1.2 → nthlayer_common-3.0.0}/src/nthlayer_common/identity/normalizer.py +0 -0
  45. {nthlayer_common-2.1.2 → nthlayer_common-3.0.0}/src/nthlayer_common/identity/ownership.py +0 -0
  46. {nthlayer_common-2.1.2 → nthlayer_common-3.0.0}/src/nthlayer_common/identity/ownership_providers/__init__.py +0 -0
  47. {nthlayer_common-2.1.2 → nthlayer_common-3.0.0}/src/nthlayer_common/identity/ownership_providers/backstage.py +0 -0
  48. {nthlayer_common-2.1.2 → nthlayer_common-3.0.0}/src/nthlayer_common/identity/ownership_providers/base.py +0 -0
  49. {nthlayer_common-2.1.2 → nthlayer_common-3.0.0}/src/nthlayer_common/identity/ownership_providers/kubernetes.py +0 -0
  50. {nthlayer_common-2.1.2 → nthlayer_common-3.0.0}/src/nthlayer_common/identity/ownership_providers/pagerduty.py +0 -0
  51. {nthlayer_common-2.1.2 → nthlayer_common-3.0.0}/src/nthlayer_common/identity/resolver.py +0 -0
  52. {nthlayer_common-2.1.2 → nthlayer_common-3.0.0}/src/nthlayer_common/llm.py +0 -0
  53. {nthlayer_common-2.1.2 → nthlayer_common-3.0.0}/src/nthlayer_common/llm_structured.py +0 -0
  54. {nthlayer_common-2.1.2 → nthlayer_common-3.0.0}/src/nthlayer_common/llm_stub.py +0 -0
  55. {nthlayer_common-2.1.2 → nthlayer_common-3.0.0}/src/nthlayer_common/manifest/models.py +0 -0
  56. {nthlayer_common-2.1.2 → nthlayer_common-3.0.0}/src/nthlayer_common/manifest/openslo/__init__.py +0 -0
  57. {nthlayer_common-2.1.2 → nthlayer_common-3.0.0}/src/nthlayer_common/manifest/parser/__init__.py +0 -0
  58. {nthlayer_common-2.1.2 → nthlayer_common-3.0.0}/src/nthlayer_common/manifest/parser/_shared.py +0 -0
  59. {nthlayer_common-2.1.2 → nthlayer_common-3.0.0}/src/nthlayer_common/manifest/parser/loader.py +0 -0
  60. {nthlayer_common-2.1.2 → nthlayer_common-3.0.0}/src/nthlayer_common/metrics.py +0 -0
  61. {nthlayer_common-2.1.2 → nthlayer_common-3.0.0}/src/nthlayer_common/outcomes.py +0 -0
  62. {nthlayer_common-2.1.2 → nthlayer_common-3.0.0}/src/nthlayer_common/overrides/__init__.py +0 -0
  63. {nthlayer_common-2.1.2 → nthlayer_common-3.0.0}/src/nthlayer_common/overrides/ingestion.py +0 -0
  64. {nthlayer_common-2.1.2 → nthlayer_common-3.0.0}/src/nthlayer_common/overrides/models.py +0 -0
  65. {nthlayer_common-2.1.2 → nthlayer_common-3.0.0}/src/nthlayer_common/parsing.py +0 -0
  66. {nthlayer_common-2.1.2 → nthlayer_common-3.0.0}/src/nthlayer_common/prompts.py +0 -0
  67. {nthlayer_common-2.1.2 → nthlayer_common-3.0.0}/src/nthlayer_common/providers/__init__.py +0 -0
  68. {nthlayer_common-2.1.2 → nthlayer_common-3.0.0}/src/nthlayer_common/providers/base.py +0 -0
  69. {nthlayer_common-2.1.2 → nthlayer_common-3.0.0}/src/nthlayer_common/providers/grafana.py +0 -0
  70. {nthlayer_common-2.1.2 → nthlayer_common-3.0.0}/src/nthlayer_common/providers/lock.py +0 -0
  71. {nthlayer_common-2.1.2 → nthlayer_common-3.0.0}/src/nthlayer_common/providers/mimir.py +0 -0
  72. {nthlayer_common-2.1.2 → nthlayer_common-3.0.0}/src/nthlayer_common/providers/pagerduty.py +0 -0
  73. {nthlayer_common-2.1.2 → nthlayer_common-3.0.0}/src/nthlayer_common/providers/prometheus.py +0 -0
  74. {nthlayer_common-2.1.2 → nthlayer_common-3.0.0}/src/nthlayer_common/providers/registry.py +0 -0
  75. {nthlayer_common-2.1.2 → nthlayer_common-3.0.0}/src/nthlayer_common/py.typed +0 -0
  76. {nthlayer_common-2.1.2 → nthlayer_common-3.0.0}/src/nthlayer_common/records/__init__.py +0 -0
  77. {nthlayer_common-2.1.2 → nthlayer_common-3.0.0}/src/nthlayer_common/records/errors.py +0 -0
  78. {nthlayer_common-2.1.2 → nthlayer_common-3.0.0}/src/nthlayer_common/records/hashing.py +0 -0
  79. {nthlayer_common-2.1.2 → nthlayer_common-3.0.0}/src/nthlayer_common/records/models.py +0 -0
  80. {nthlayer_common-2.1.2 → nthlayer_common-3.0.0}/src/nthlayer_common/records/sqlite_store.py +0 -0
  81. {nthlayer_common-2.1.2 → nthlayer_common-3.0.0}/src/nthlayer_common/records/store.py +0 -0
  82. {nthlayer_common-2.1.2 → nthlayer_common-3.0.0}/src/nthlayer_common/records/verdict_bridge.py +0 -0
  83. {nthlayer_common-2.1.2 → nthlayer_common-3.0.0}/src/nthlayer_common/records/verification.py +0 -0
  84. {nthlayer_common-2.1.2 → nthlayer_common-3.0.0}/src/nthlayer_common/slack.py +0 -0
  85. {nthlayer_common-2.1.2 → nthlayer_common-3.0.0}/src/nthlayer_common/slack_web.py +0 -0
  86. {nthlayer_common-2.1.2 → nthlayer_common-3.0.0}/src/nthlayer_common/slo_models.py +0 -0
  87. {nthlayer_common-2.1.2 → nthlayer_common-3.0.0}/src/nthlayer_common/telemetry.py +0 -0
  88. {nthlayer_common-2.1.2 → nthlayer_common-3.0.0}/src/nthlayer_common/tiers.py +0 -0
  89. {nthlayer_common-2.1.2 → nthlayer_common-3.0.0}/src/nthlayer_common/verdicts/__init__.py +0 -0
  90. {nthlayer_common-2.1.2 → nthlayer_common-3.0.0}/src/nthlayer_common/verdicts/core.py +0 -0
  91. {nthlayer_common-2.1.2 → nthlayer_common-3.0.0}/src/nthlayer_common/verdicts/models.py +0 -0
  92. {nthlayer_common-2.1.2 → nthlayer_common-3.0.0}/src/nthlayer_common/verdicts/serialise.py +0 -0
  93. {nthlayer_common-2.1.2 → nthlayer_common-3.0.0}/src/nthlayer_common/verdicts/sqlite_store.py +0 -0
  94. {nthlayer_common-2.1.2 → nthlayer_common-3.0.0}/src/nthlayer_common/verdicts/store.py +0 -0
  95. {nthlayer_common-2.1.2 → nthlayer_common-3.0.0}/src/nthlayer_common.egg-info/dependency_links.txt +0 -0
  96. {nthlayer_common-2.1.2 → nthlayer_common-3.0.0}/src/nthlayer_common.egg-info/requires.txt +0 -0
  97. {nthlayer_common-2.1.2 → nthlayer_common-3.0.0}/src/nthlayer_common.egg-info/top_level.txt +0 -0
  98. {nthlayer_common-2.1.2 → nthlayer_common-3.0.0}/tests/test_api_client.py +0 -0
  99. {nthlayer_common-2.1.2 → nthlayer_common-3.0.0}/tests/test_cloudevents.py +0 -0
  100. {nthlayer_common-2.1.2 → nthlayer_common-3.0.0}/tests/test_config.py +0 -0
  101. {nthlayer_common-2.1.2 → nthlayer_common-3.0.0}/tests/test_errors.py +0 -0
  102. {nthlayer_common-2.1.2 → nthlayer_common-3.0.0}/tests/test_explanation.py +0 -0
  103. {nthlayer_common-2.1.2 → nthlayer_common-3.0.0}/tests/test_governance_bridge.py +0 -0
  104. {nthlayer_common-2.1.2 → nthlayer_common-3.0.0}/tests/test_identity.py +0 -0
  105. {nthlayer_common-2.1.2 → nthlayer_common-3.0.0}/tests/test_llm.py +0 -0
  106. {nthlayer_common-2.1.2 → nthlayer_common-3.0.0}/tests/test_llm_structured.py +0 -0
  107. {nthlayer_common-2.1.2 → nthlayer_common-3.0.0}/tests/test_llm_stub.py +0 -0
  108. {nthlayer_common-2.1.2 → nthlayer_common-3.0.0}/tests/test_manifest_real_specs.py +0 -0
  109. {nthlayer_common-2.1.2 → nthlayer_common-3.0.0}/tests/test_manifest_v2_archetypes.py +0 -0
  110. {nthlayer_common-2.1.2 → nthlayer_common-3.0.0}/tests/test_metrics.py +0 -0
  111. {nthlayer_common-2.1.2 → nthlayer_common-3.0.0}/tests/test_models.py +0 -0
  112. {nthlayer_common-2.1.2 → nthlayer_common-3.0.0}/tests/test_outcomes.py +0 -0
  113. {nthlayer_common-2.1.2 → nthlayer_common-3.0.0}/tests/test_overrides.py +0 -0
  114. {nthlayer_common-2.1.2 → nthlayer_common-3.0.0}/tests/test_prompts.py +0 -0
  115. {nthlayer_common-2.1.2 → nthlayer_common-3.0.0}/tests/test_providers.py +0 -0
  116. {nthlayer_common-2.1.2 → nthlayer_common-3.0.0}/tests/test_slack.py +0 -0
  117. {nthlayer_common-2.1.2 → nthlayer_common-3.0.0}/tests/test_slack_web.py +0 -0
  118. {nthlayer_common-2.1.2 → nthlayer_common-3.0.0}/tests/test_target_validation.py +0 -0
  119. {nthlayer_common-2.1.2 → nthlayer_common-3.0.0}/tests/test_telemetry.py +0 -0
  120. {nthlayer_common-2.1.2 → nthlayer_common-3.0.0}/tests/test_tier_errors.py +0 -0
  121. {nthlayer_common-2.1.2 → nthlayer_common-3.0.0}/tests/test_tiers.py +0 -0
  122. {nthlayer_common-2.1.2 → nthlayer_common-3.0.0}/tests/test_verdicts_core.py +0 -0
  123. {nthlayer_common-2.1.2 → nthlayer_common-3.0.0}/tests/test_verdicts_models.py +0 -0
  124. {nthlayer_common-2.1.2 → nthlayer_common-3.0.0}/tests/test_verdicts_serialise.py +0 -0
  125. {nthlayer_common-2.1.2 → nthlayer_common-3.0.0}/tests/test_verdicts_sqlite_concurrency.py +0 -0
  126. {nthlayer_common-2.1.2 → nthlayer_common-3.0.0}/tests/test_verdicts_store.py +0 -0
  127. {nthlayer_common-2.1.2 → nthlayer_common-3.0.0}/tests/test_verdicts_v15_fields.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: nthlayer-common
3
- Version: 2.1.2
3
+ Version: 3.0.0
4
4
  Summary: Shared utilities for the NthLayer ecosystem
5
5
  License: Apache-2.0
6
6
  Requires-Python: >=3.11
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "nthlayer-common"
7
- version = "2.1.2"
7
+ version = "3.0.0"
8
8
  description = "Shared utilities for the NthLayer ecosystem"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.11"
@@ -75,8 +75,12 @@ from nthlayer_common.manifest.parser.v1 import OpenSRMParseError
75
75
  from nthlayer_common.manifest.parser.v2 import OpenSRMV2ParseError
76
76
  from nthlayer_common.manifest.scan import (
77
77
  MANIFEST_SUFFIXES,
78
+ ManifestCollisionWarning,
79
+ ManifestScan,
80
+ SuffixCollision,
78
81
  foreign_yaml_reason,
79
82
  iter_manifest_files,
83
+ scan_manifest_files,
80
84
  )
81
85
 
82
86
  __all__ = [
@@ -102,8 +106,12 @@ __all__ = [
102
106
  "valid_service_types_phrase",
103
107
  # Directory scanning
104
108
  "MANIFEST_SUFFIXES",
109
+ "ManifestCollisionWarning",
110
+ "ManifestScan",
111
+ "SuffixCollision",
105
112
  "foreign_yaml_reason",
106
113
  "iter_manifest_files",
114
+ "scan_manifest_files",
107
115
  # Enums
108
116
  "DependencyCriticality",
109
117
  "SourceFormat",
@@ -16,6 +16,10 @@ from typing import Any
16
16
  import yaml
17
17
 
18
18
  from nthlayer_common.manifest.models import SLODefinition
19
+ from nthlayer_common.manifest.target_validation import (
20
+ check_finite,
21
+ require_number,
22
+ )
19
23
 
20
24
 
21
25
  class OpenSLOParseError(Exception):
@@ -114,9 +118,7 @@ def _parse_openslo_document(data: dict[str, Any]) -> SLODefinition:
114
118
  raise OpenSLOParseError(f"OpenSLO '{name}' has no objectives")
115
119
 
116
120
  objective = objectives[0]
117
- target = objective.get("target")
118
- if target is None:
119
- raise OpenSLOParseError(f"OpenSLO '{name}' objective missing target")
121
+ target = _objective_target_percent(objective, name)
120
122
 
121
123
  # Parse indicator
122
124
  indicator = spec.get("indicator", {})
@@ -182,8 +184,11 @@ def _parse_openslo_document(data: dict[str, Any]) -> SLODefinition:
182
184
  description = objective.get("displayName") or metadata.get("displayName")
183
185
 
184
186
  return SLODefinition(
187
+ # No float(): _objective_target_percent already returns one, having gone
188
+ # through require_number. The redundant cast was a leftover from when
189
+ # this read objective["target"] directly [opensrm-ocvu].
185
190
  name=name,
186
- target=float(target),
191
+ target=target,
187
192
  slo_type=slo_type,
188
193
  window=window,
189
194
  unit=unit,
@@ -195,6 +200,74 @@ def _parse_openslo_document(data: dict[str, Any]) -> SLODefinition:
195
200
  )
196
201
 
197
202
 
203
+ def _objective_target_percent(objective: dict[str, Any], name: str) -> float:
204
+ """An OpenSLO objective's target, as the canonical 0-100 percentage.
205
+
206
+ OpenSLO accepts EITHER ``target`` (a fraction in [0,1)) OR ``targetPercent``
207
+ (0-100), exactly one of them. NthLayer's internal convention is 0-100 for
208
+ every consumer (nthlayer-common CLAUDE.md hard rule 1), so a fraction is
209
+ converted here — at the inbound boundary — and a percentage is taken as-is.
210
+
211
+ This boundary was missing entirely [opensrm-ocvu]. The outbound one has
212
+ always existed (nthlayer_generate/slos/pipeline.py divides by 100), so a
213
+ ratio reached SLODefinition.target unconverted and every consumer comparing
214
+ a target to a measured value got an answer 100x out, silently, depending
215
+ only on which format the manifest happened to be written in.
216
+
217
+ It also broke the documented v1 -> v2 migration: v1_compat converts a v1
218
+ percentage to a ratio for the OpenSLO document it emits, and reading that
219
+ back without converting turned a 99.9 target into 0.999. The module
220
+ promised the output "round-trips through parse_opensrm_v2"; it did not.
221
+ """
222
+ has_target = "target" in objective and objective["target"] is not None
223
+ has_percent = (
224
+ "targetPercent" in objective and objective["targetPercent"] is not None
225
+ )
226
+
227
+ if has_target and has_percent:
228
+ # OpenSLO requires exactly one. Picking a winner here would let two
229
+ # disagreeing values sit in a manifest with only one taking effect.
230
+ raise OpenSLOParseError(
231
+ f"OpenSLO '{name}' objective sets both target and targetPercent; "
232
+ f"OpenSLO permits exactly one"
233
+ )
234
+ # Both branches go through require_number + check_finite, the same guards
235
+ # the JUDGMENT boundary uses, re-raised as this parser's own error type
236
+ # [opensrm-ocvu]. Without them this function — which this bead added — had
237
+ # the two defects the judgment side had already been fixed for:
238
+ #
239
+ # target: .nan -> SLODefinition.target = nan, and with a contract
240
+ # promise validate_contracts() returned [], a breach
241
+ # reporting CLEAN. Identical outcome, identical field.
242
+ # target: {} -> bare TypeError out of float(), which is not a type
243
+ # this parser declares.
244
+ #
245
+ # RANGE is deliberately NOT checked here. `targetPercent: 50` meaning "50%"
246
+ # versus `target: 50` meaning a 5000% floor is a real ambiguity, but
247
+ # rejecting it would also reject `target` values this parser accepts today,
248
+ # and plain `target` has been unranged since long before this bead. That is
249
+ # a scoped decision, not a fix to make inside one.
250
+ try:
251
+ if has_percent:
252
+ value = require_number(
253
+ "targetPercent", objective["targetPercent"],
254
+ what="objective target",
255
+ )
256
+ check_finite("targetPercent", value, what="objective target")
257
+ return value
258
+ if has_target:
259
+ value = require_number(
260
+ "target", objective["target"], what="objective target",
261
+ )
262
+ check_finite("target", value, what="objective target")
263
+ return value * 100.0
264
+ except ValueError as exc:
265
+ raise OpenSLOParseError(f"OpenSLO '{name}': {exc}") from exc
266
+ raise OpenSLOParseError(
267
+ f"OpenSLO '{name}' objective missing target (or targetPercent)"
268
+ )
269
+
270
+
198
271
  def _extract_query(metric_source: dict[str, Any]) -> str | None:
199
272
  """Extract PromQL query from an OpenSLO metricSource."""
200
273
  ms_type = metric_source.get("type", "")
@@ -42,6 +42,10 @@ from nthlayer_common.manifest.models import (
42
42
  TelemetryEvent,
43
43
  )
44
44
  from nthlayer_common.manifest.parser._shared import parse_observability
45
+ from nthlayer_common.manifest.target_validation import (
46
+ check_finite,
47
+ require_number,
48
+ )
45
49
  from nthlayer_common.manifest.v1_compat import (
46
50
  convert_v1_contract,
47
51
  default_measurement,
@@ -287,9 +291,27 @@ def _parse_slos(
287
291
  measurement = default_measurement(judgment_type, window)
288
292
  statistical_requirements = default_statistical_requirements(judgment_type)
289
293
 
294
+ # Guarded the same way as the v2 classical path [opensrm-ocvu]. v1 was
295
+ # the LAST unguarded writer of SLODefinition.target, and leaving it so
296
+ # would have recreated this bead's own subject one level down: v2
297
+ # classical rejecting a NaN target while v1 classical accepted it.
298
+ # Measured before the guard — `slos: {availability: {target: .nan}}`
299
+ # with a contract gave target=nan and validate_contracts() -> [], a
300
+ # breach reporting CLEAN; `target: {}` gave a bare TypeError.
301
+ #
302
+ # RANGE is not checked, matching the v2 classical path exactly: plain
303
+ # targets have been unranged in both formats since long before this
304
+ # bead, and rejecting out-of-range would reject manifests that parse
305
+ # today. That remains a scoped decision.
306
+ try:
307
+ numeric_target = require_number(name, target, what="SLO target")
308
+ check_finite(name, numeric_target, what="SLO target")
309
+ except ValueError as exc:
310
+ raise OpenSRMParseError(f"SLO '{name}': {exc}") from exc
311
+
290
312
  slo = SLODefinition(
291
313
  name=name,
292
- target=float(target),
314
+ target=numeric_target,
293
315
  slo_type=slo_type,
294
316
  window=config.get("window", "30d"),
295
317
  unit=config.get("unit"),
@@ -28,7 +28,6 @@ from nthlayer_common.manifest.models import (
28
28
  FallbackDeclaration,
29
29
  Instrumentation,
30
30
  JudgmentMeasurement,
31
- JudgmentPromise,
32
31
  Outcomes,
33
32
  Ownership,
34
33
  ProbeConfig,
@@ -53,6 +52,11 @@ from nthlayer_common.manifest.openslo.parser import (
53
52
  parse_openslo_slos,
54
53
  )
55
54
  from nthlayer_common.manifest.parser._shared import parse_observability
55
+ from nthlayer_common.manifest.target_validation import (
56
+ JUDGMENT_TARGET_FIELDS,
57
+ judgment_promise,
58
+ judgment_target_percent,
59
+ )
56
60
 
57
61
  logger = structlog.get_logger()
58
62
 
@@ -410,19 +414,7 @@ def _extract_judgment_target(
410
414
 
411
415
  Each judgment type has a different target field name in the spec.
412
416
  """
413
- # Type-specific target field names
414
- target_fields: dict[str, str] = {
415
- "reversal_rate": "maximum_reversal_rate",
416
- "high_confidence_failure": "maximum_failure_rate",
417
- "audit_sampling": "audit_completion_rate",
418
- "outcomes": "desired_outcome_rate",
419
- "escalation": "maximum_escalation_rate",
420
- "segments": "maximum_variance_from_overall",
421
- "stability": "maximum_drift",
422
- "calibration": "maximum_brier_score",
423
- }
424
-
425
- field_name = target_fields.get(judgment_type)
417
+ field_name = JUDGMENT_TARGET_FIELDS.get(judgment_type)
426
418
  if not field_name:
427
419
  raise OpenSRMV2ParseError(
428
420
  f"No target field mapping for judgment_type '{judgment_type}'"
@@ -434,7 +426,15 @@ def _extract_judgment_target(
434
426
  f"Judgment SLO '{name}' missing target.{field_name}"
435
427
  )
436
428
 
437
- return float(value)
429
+ # No float() here — judgment_target_percent validates the type itself and
430
+ # raises ValueError, whereas float() raised TypeError for None / dict /
431
+ # list, a type this parser does not declare. Re-raised as this parser's own
432
+ # error so a malformed target is indistinguishable in KIND from a missing
433
+ # one, which is raised above [opensrm-ocvu].
434
+ try:
435
+ return judgment_target_percent(field_name, value)
436
+ except ValueError as exc:
437
+ raise OpenSRMV2ParseError(f"Judgment SLO '{name}': {exc}") from exc
438
438
 
439
439
 
440
440
  def _parse_judgment_measurement(
@@ -556,11 +556,16 @@ def _parse_contracts(contracts_data: list[dict[str, Any]]) -> list[ReliabilityCo
556
556
  promise_data = c_data.get("promise", {})
557
557
  judgment_promises = []
558
558
  for jtype, threshold in promise_data.get("judgment", {}).items():
559
- judgment_promises.append(JudgmentPromise(
560
- judgment_type=jtype,
561
- threshold=float(threshold),
562
- direction="below", # contract thresholds are maximums (lower is better)
563
- ))
559
+ # Threshold and direction decided together, in one place shared with
560
+ # the v1 path — see judgment_promise() [opensrm-ocvu]. Re-raised as
561
+ # this parser's own error type; a `judgment: {reversal_rate:}` typo
562
+ # otherwise escaped as a bare TypeError.
563
+ try:
564
+ judgment_promises.append(judgment_promise(jtype, threshold))
565
+ except ValueError as exc:
566
+ raise OpenSRMV2ParseError(
567
+ f"Contract '{name}' promise: {exc}"
568
+ ) from exc
564
569
 
565
570
  promise = ContractPromise(
566
571
  availability=promise_data.get("availability"),
@@ -0,0 +1,375 @@
1
+ """Scanning a directory that holds manifests alongside other YAML.
2
+
3
+ ``load_manifest`` raises ``ManifestLoadError`` for any YAML it cannot parse
4
+ as a manifest, including files that never claimed to be one. A caller
5
+ walking a specs directory therefore has to answer a question the parser
6
+ does not: was this file *aiming* to be a manifest and failing, or is it
7
+ something else sharing the directory?
8
+
9
+ Both wrong answers are real bugs, and both have happened:
10
+
11
+ - Treat everything as a broken manifest, and a "computed over a subset"
12
+ caveat fires on every mixed-directory run. A caveat that always fires
13
+ stops being read.
14
+ - Treat nothing as one, and a broken manifest drops out of a financial
15
+ figure or a set of measured SLOs without a word (opensrm-oh27,
16
+ opensrm-3470).
17
+
18
+ Promoted here from ``nthlayer_workers.learn.retrospective`` once a third
19
+ consumer appeared, so the call sites stop reaching into one another
20
+ (opensrm-3470).
21
+ """
22
+
23
+ from __future__ import annotations
24
+
25
+ import unicodedata
26
+ import warnings
27
+ from pathlib import Path
28
+ from typing import NamedTuple
29
+
30
+ import yaml
31
+
32
+ from nthlayer_common.manifest.models import is_valid_service_type
33
+ from nthlayer_common.manifest.parser.v1 import is_srm_v1_format
34
+ from nthlayer_common.manifest.parser.v2 import is_opensrm_v2_format
35
+
36
+
37
+ class ManifestCollisionWarning(UserWarning):
38
+ """Two manifest files shared a stem, so one was not loaded [opensrm-xvwt].
39
+
40
+ A warning rather than a structlog event, because this is a LIBRARY.
41
+ nthlayer-common never calls structlog.configure, so the default
42
+ PrintLogger writes to STDOUT — measured — and a consumer CLI emitting
43
+ machine-readable output on stdout would have a human-formatted log line
44
+ interleaved into it. warnings go to stderr, are filterable by category,
45
+ and are the pattern this package already uses for an authoring mistake it
46
+ should flag loudly without rejecting: see TargetConventionWarning in
47
+ target_validation.py.
48
+
49
+ The STRUCTURED form of the same information is scan_manifest_files()'
50
+ return value, which is what a caller that wants to act on it should use.
51
+ """
52
+
53
+
54
+ # One home for the wording, and it names the actual risk rather than the
55
+ # mechanism: an operator does not care that two stems matched, they care that a
56
+ # service they declared is not being measured.
57
+ _COLLISION_HINT = (
58
+ "If they declare DIFFERENT services, rename one: the dropped file is not "
59
+ "being measured."
60
+ )
61
+
62
+ # Suffixes a manifest may carry. Both, always: `.yml` invisibility is the
63
+ # same silent-subset failure as an uncounted parse error, reached by file
64
+ # extension instead — the manifest is dropped and nothing says so.
65
+ MANIFEST_SUFFIXES = (".yaml", ".yml")
66
+
67
+
68
+ def _resolve_collision(group: list[Path]) -> tuple[Path, list[Path]]:
69
+ """Which of several same-stem manifests to keep, and which to report.
70
+
71
+ A separate named function so the rule can be exercised WITHOUT a
72
+ filesystem. The three-way case needs `svc.yaml` and `svc.YAML` to coexist,
73
+ which a case-insensitive filesystem cannot do — so a temp-directory
74
+ fixture skipped on macOS and ran in Linux CI, and the skip hid an
75
+ assertion that was wrong. See
76
+ tests/test_manifest_scan.py::test_collision_precedence_is_deterministic.
77
+
78
+ Three ranks, in order:
79
+
80
+ 1. MANIFEST_SUFFIXES order IS the precedence, so the rule and the tuple
81
+ cannot drift apart. ``.yaml`` is what the ecosystem writes, and that is
82
+ counted rather than asserted: across nthlayer/demo and opensrm/spec,
83
+ 46 manifests are ``.yaml`` and 0 are ``.yml`` or a case variant.
84
+ 2. The EXACTLY-lowercase spelling beats a case variant. Without this rank
85
+ the name tiebreak kept ``svc.YAML`` over ``svc.yaml``, because
86
+ ``"svc.YAML" < "svc.yaml"`` in ASCII.
87
+ 3. Name, so the choice is never filesystem-order dependent.
88
+
89
+ PRECONDITION: every path's case-folded suffix is in MANIFEST_SUFFIXES.
90
+ scan_manifest_files() guarantees it by filtering on the same key; a direct
91
+ caller that does not will get ValueError from .index(), which is the right
92
+ outcome for a programming error but is not a guard.
93
+ """
94
+ kept, *dropped = sorted(
95
+ group,
96
+ key=lambda p: (
97
+ MANIFEST_SUFFIXES.index(p.suffix.lower()),
98
+ 0 if p.suffix == p.suffix.lower() else 1,
99
+ p.name,
100
+ ),
101
+ )
102
+ return kept, dropped
103
+
104
+
105
+ class SuffixCollision(NamedTuple):
106
+ """One stem declared twice, once per manifest suffix [opensrm-xvwt].
107
+
108
+ NamedTuple rather than the @dataclass this package uses for models: these
109
+ two are a return shape, not a domain object — no validation, no behaviour,
110
+ and callers unpack them positionally.
111
+ """
112
+
113
+ stem: str
114
+ kept: Path
115
+ dropped: Path
116
+
117
+
118
+ class ManifestScan(NamedTuple):
119
+ """What a directory scan found, and what it had to choose between."""
120
+
121
+ files: list[Path]
122
+ suffix_collisions: list[SuffixCollision]
123
+
124
+
125
+ def scan_manifest_files(specs_dir: str | Path) -> ManifestScan:
126
+ """Manifest files under *specs_dir*, with same-stem collisions resolved.
127
+
128
+ A directory holding BOTH ``svc.yaml`` and ``svc.yml`` — the ordinary
129
+ result of an editor default changing, or two people adding the same
130
+ service — previously yielded both [opensrm-xvwt]. Neither is foreign
131
+ YAML, so foreign_yaml_reason() passes them and both parse, and the
132
+ consequences were silent in two places:
133
+
134
+ - measure's load_specs appended the service's SLOs twice, so
135
+ evaluate_slos wrote two verdicts per window for one service/SLO. Since
136
+ count_consecutive_breaches counts VERDICTS rather than windows, a
137
+ 3-window hysteresis threshold was reached in 2 real windows or fewer.
138
+ A judgment SLO firing early is the same class of wrong as one that
139
+ never fires.
140
+ - observe's per-service SLO counts, and any budget arithmetic derived
141
+ from them, were inflated.
142
+
143
+ Nothing errored and nothing warned, because both files are valid
144
+ manifests.
145
+
146
+ ``.yaml`` wins, deterministically — it is the suffix the ecosystem
147
+ writes — with the name as tiebreak so the choice cannot vary by
148
+ filesystem order. The dropped path is RETURNED rather than only logged,
149
+ which is the same reason opensrm-3470 added a parse-failure report: this
150
+ is a partial view of the directory, and the operator reading SLO output
151
+ is who needs to know a manifest was set aside.
152
+
153
+ STEM, not parsed service name. Two files can declare the same service
154
+ under unrelated names, and scan.py deliberately does not parse — that is
155
+ what foreign_yaml_reason() exists to avoid. So this catches the common
156
+ shape the bead describes and not every possible duplicate; a caller that
157
+ needs the stronger property has to compare parsed names itself.
158
+ """
159
+ path = Path(specs_dir)
160
+ if not path.is_dir():
161
+ return ManifestScan([], [])
162
+
163
+ # `not p.is_dir()` rather than `p.is_file()`: excluding DIRECTORIES was
164
+ # the point — a directory named `foo.yaml` would be counted as a broken
165
+ # manifest by every caller. is_file() also excludes a dangling symlink,
166
+ # which silently drops a stale overlay link that previously reached
167
+ # load_manifest and was counted.
168
+ #
169
+ # Suffix compared case-folded: `.YAML` was excluded outright — never
170
+ # loaded, never counted, never logged — which is the silent-subset-by-
171
+ # extension failure MANIFEST_SUFFIXES exists to prevent, one case-fold
172
+ # away.
173
+ # This sort is now BELT-AND-BRACES, kept rather than removed. Both return
174
+ # values are re-sorted below and _resolve_collision sorts its own group, so
175
+ # removing it changes nothing — proven, not assumed: a differential over
176
+ # 4,000 random directory shapes x 3 orderings found zero output
177
+ # differences. It stays because iterdir order is arbitrary and a future
178
+ # reader should not have to re-derive that the rest of this function is
179
+ # order-independent.
180
+ candidates = sorted(
181
+ p
182
+ for p in path.iterdir()
183
+ if not p.is_dir() and p.suffix.lower() in MANIFEST_SUFFIXES
184
+ )
185
+
186
+ by_stem: dict[str, list[Path]] = {}
187
+ for candidate in candidates:
188
+ # NFC-normalised, for the same reason the suffix is case-folded: two
189
+ # spellings of one name must not become two services. APFS PRESERVES
190
+ # normalisation rather than enforcing it, so `café.yaml` written NFC
191
+ # and `café.yml` written NFD are two directory entries with different
192
+ # byte stems — visually one stem. Measured before this: both were
193
+ # returned and no collision reported, so the double-count this function
194
+ # exists to stop survived for any non-ASCII filename.
195
+ # The stem is NOT case-folded, deliberately, while the suffix is. A
196
+ # suffix is a format marker, so `.YAML` and `.yaml` mean the same
197
+ # thing; a stem is a name, and `SVC.yaml` beside `svc.yml` is two
198
+ # names an operator can tell apart. Both of the bead's shapes — an
199
+ # editor default changing, two people adding the same service — produce
200
+ # an IDENTICAL stem, so folding case here would merge files that were
201
+ # never one service to catch a case that does not occur.
202
+ by_stem.setdefault(
203
+ unicodedata.normalize("NFC", candidate.stem), []
204
+ ).append(candidate)
205
+
206
+ files: list[Path] = []
207
+ collisions: list[SuffixCollision] = []
208
+ for stem, group in by_stem.items():
209
+ if len(group) == 1:
210
+ files.append(group[0])
211
+ continue
212
+ kept, dropped = _resolve_collision(group)
213
+ files.append(kept)
214
+ collisions.extend(SuffixCollision(stem, kept, d) for d in dropped)
215
+
216
+ return ManifestScan(
217
+ sorted(files),
218
+ sorted(collisions, key=lambda c: (c.stem, c.dropped.name)),
219
+ )
220
+
221
+
222
+ def iter_manifest_files(specs_dir: str | Path) -> list[Path]:
223
+ """Manifest files under ``specs_dir``, sorted, ONE PER STEM.
224
+
225
+ A lossy view of scan_manifest_files(): same files, collisions raised as a
226
+ ManifestCollisionWarning rather than returned. Its signature is fixed — three consumers in
227
+ nthlayer-workers call it — so the de-duplication had to arrive without one,
228
+ and that is why this exists alongside the fuller function rather than
229
+ instead of it. A caller that should SURFACE a set-aside file to an operator
230
+ wants scan_manifest_files().
231
+
232
+ IT WARNS ONCE PER PROCESS, NOT ONCE PER SCAN. Python's default filter
233
+ dedupes on (message, category, module, lineno), and with stacklevel=2 the
234
+ key is the CALLER's line — so a worker re-scanning the same directory every
235
+ window warns on the first window and is silent thereafter. Quieter, but an
236
+ operator who misses the first one never sees it again, which is why the
237
+ three consumers should move to scan_manifest_files() and surface the
238
+ collision themselves: opensrm-j9wq.
239
+
240
+ NOTHING DROPS SILENTLY, and that is not belt-and-braces: grouping is by
241
+ stem, so a collision can be two DIFFERENT services rather than a duplicate,
242
+ and dropping one of those without a trace is the silent-subset failure this
243
+ module exists to prevent (opensrm-oh27, opensrm-3470). See
244
+ scan_manifest_files() for the full argument.
245
+
246
+ Sorted so the order is stable across machines. That matters to any caller
247
+ that dedupes first-wins — learn/retrospective does; observe's spec_loader
248
+ does not, and whether it should is opensrm-3470's follow-up rather than a
249
+ property this function can assume.
250
+
251
+ Returns empty for a path that is not a directory rather than raising, so a
252
+ caller that wants its own error message for that case keeps the decision.
253
+ """
254
+ scan = scan_manifest_files(specs_dir)
255
+ for collision in scan.suffix_collisions:
256
+ warnings.warn(
257
+ f"{collision.dropped} was not loaded: it shares the stem "
258
+ f"'{collision.stem}' with {collision.kept.name}. "
259
+ f"{_COLLISION_HINT}",
260
+ ManifestCollisionWarning,
261
+ # 2 = this function's caller. Measured, not copied: a deeper value
262
+ # names scan.py itself, which tells the reader nothing about which
263
+ # directory scan produced it.
264
+ stacklevel=2,
265
+ )
266
+ return scan.files
267
+
268
+
269
+ def foreign_yaml_reason(spec_file: str | Path) -> str | None:
270
+ """Why a file that failed to load should NOT count as a broken manifest.
271
+
272
+ Returns ``None`` when the file was aiming at being a manifest — so the
273
+ caller counts it — or a short reason when it plainly was not, so the
274
+ caller can drop it and log the reason rather than dropping it silently.
275
+
276
+ Evidence of intent is checked in three widening steps: the canonical
277
+ v1/v2 format predicates, so this cannot drift from what the parser
278
+ accepts; then a near miss on those headers, since a typo'd kind or a
279
+ drifted API group is an ordinary way a real manifest breaks; then the
280
+ body, since a bad merge or a mis-indent can strip the header while
281
+ leaving ``spec.service`` (as a *mapping*), ``spec.slos`` or
282
+ ``spec.outcomes`` intact.
283
+
284
+ ``spec.service`` as a mapping is what separates a headerless manifest
285
+ from a headerless OpenSLO document, which writes the same key as a
286
+ plain string. Anything unreadable or empty counts too — a syntax error
287
+ or a truncated write inside a specs directory is a deployment error
288
+ either way.
289
+
290
+ LIMIT, stated rather than deferred: this recovers intent only while
291
+ evidence of intent survives. A file that has lost both its header and
292
+ its body is indistinguishable in principle from any other headerless
293
+ YAML mapping — both are dicts with no apiVersion, no kind, no
294
+ ``spec.service``. No content heuristic separates those, and adding more
295
+ markers would not change it. For such a file this returns a reason, so
296
+ the caller can record it rather than drop it without trace — which is
297
+ what lets an investigator chasing a number that looks wrong pick the
298
+ trail back up.
299
+ """
300
+ path = Path(spec_file)
301
+ try:
302
+ data = yaml.safe_load(path.read_text())
303
+ except (OSError, ValueError, yaml.YAMLError):
304
+ # ValueError covers UnicodeDecodeError on non-UTF-8 bytes. Too
305
+ # malformed to inspect, so it counts. A read failing here where
306
+ # load_manifest's read succeeded also means the file moved
307
+ # underneath us, which is not evidence it was foreign.
308
+ return None
309
+
310
+ if not data:
311
+ # None (zero bytes, whitespace, comments) or an empty container. A
312
+ # truncated or interrupted write is precisely what the count exists
313
+ # for.
314
+ return None
315
+
316
+ if not isinstance(data, dict):
317
+ return f"top-level YAML is {type(data).__name__}, not a mapping"
318
+
319
+ # "service" at the top level is the legacy pre-apiVersion shape that
320
+ # load_manifest still accepts.
321
+ if is_opensrm_v2_format(data) or is_srm_v1_format(data) or "service" in data:
322
+ return None
323
+
324
+ api_version = data.get("apiVersion")
325
+ kind = data.get("kind")
326
+ near_miss = (
327
+ isinstance(api_version, str)
328
+ and (api_version.startswith("srm/") or "opensrm" in api_version)
329
+ ) or (
330
+ isinstance(kind, str)
331
+ and kind.startswith(("ServiceManifest", "ServiceReliabilityManifest"))
332
+ )
333
+ if near_miss:
334
+ return None
335
+
336
+ # Body recovery: a bad merge or a mis-indent can strip the header while
337
+ # leaving the body intact, and that is the common corruption.
338
+ #
339
+ # The key names differ by format, and getting them wrong makes this whole
340
+ # branch inert — which it was until opensrm-3470's edge-cases pass:
341
+ # v1 spec.slos a MAPPING of name -> config (parser/v1 does
342
+ # slos_data.items()), not a list
343
+ # v2 spec.slo a list of OpenSLO documents
344
+ # spec.judgment_slo a list
345
+ # spec.service a MAPPING; OpenSLO writes the same key as a
346
+ # plain string, which is what keeps OpenSLO out
347
+ # spec.outcomes a mapping
348
+ # v1 spec.type REQUIRED by parse_srm_v1 (v1.py:91), and
349
+ # checked against is_valid_service_type rather
350
+ # than "is a string": a Backstage catalog-info
351
+ # carries spec.type too (`service`, `library`),
352
+ # and counting those makes every Backstage
353
+ # Component in the directory a broken manifest
354
+ # v2 spec.owner REQUIRED by parse_opensrm_v2 (v2.py:104)
355
+ # spec.slos is accepted as either shape: v1 writes a mapping, and a
356
+ # partially-converted file is exactly the kind of thing that lands here.
357
+ #
358
+ # The required keys matter most: a file carrying the one key its format
359
+ # cannot be parsed without makes a stronger claim to being a manifest
360
+ # than any of the optional ones above. Checking only the optional keys
361
+ # was the same mistake as testing spec.slos for the wrong type —
362
+ # reasoning about shapes without asking what the parsers demand.
363
+ spec = data.get("spec")
364
+ if isinstance(spec, dict) and (
365
+ isinstance(spec.get("service"), dict)
366
+ or isinstance(spec.get("slos"), (dict, list))
367
+ or isinstance(spec.get("slo"), list)
368
+ or isinstance(spec.get("judgment_slo"), list)
369
+ or isinstance(spec.get("outcomes"), dict)
370
+ or is_valid_service_type(spec.get("type"))
371
+ or isinstance(spec.get("owner"), dict)
372
+ ):
373
+ return None
374
+
375
+ return f"no manifest markers (apiVersion={api_version!r}, kind={kind!r})"