colca-data-contracts 0.2.0__tar.gz → 0.3.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 (66) hide show
  1. {colca_data_contracts-0.2.0 → colca_data_contracts-0.3.0}/PKG-INFO +1 -1
  2. {colca_data_contracts-0.2.0 → colca_data_contracts-0.3.0}/pyproject.toml +1 -1
  3. {colca_data_contracts-0.2.0 → colca_data_contracts-0.3.0}/src/colca_data_contracts/__init__.py +7 -0
  4. {colca_data_contracts-0.2.0 → colca_data_contracts-0.3.0}/src/colca_data_contracts/payload.py +66 -0
  5. {colca_data_contracts-0.2.0 → colca_data_contracts-0.3.0}/src/colca_data_contracts/routing.py +3 -0
  6. {colca_data_contracts-0.2.0 → colca_data_contracts-0.3.0}/src/colca_data_contracts.egg-info/PKG-INFO +1 -1
  7. {colca_data_contracts-0.2.0 → colca_data_contracts-0.3.0}/tests/test_alarm_payloads.py +76 -0
  8. {colca_data_contracts-0.2.0 → colca_data_contracts-0.3.0}/tests/test_bundle_generator.py +43 -0
  9. {colca_data_contracts-0.2.0 → colca_data_contracts-0.3.0}/README.md +0 -0
  10. {colca_data_contracts-0.2.0 → colca_data_contracts-0.3.0}/setup.cfg +0 -0
  11. {colca_data_contracts-0.2.0 → colca_data_contracts-0.3.0}/src/colca_data_contracts/annotation_types/platform.yaml +0 -0
  12. {colca_data_contracts-0.2.0 → colca_data_contracts-0.3.0}/src/colca_data_contracts/architecture_metadata.py +0 -0
  13. {colca_data_contracts-0.2.0 → colca_data_contracts-0.3.0}/src/colca_data_contracts/data_models/__init__.py +0 -0
  14. {colca_data_contracts-0.2.0 → colca_data_contracts-0.3.0}/src/colca_data_contracts/data_models/loader.py +0 -0
  15. {colca_data_contracts-0.2.0 → colca_data_contracts-0.3.0}/src/colca_data_contracts/data_models/machine.yaml +0 -0
  16. {colca_data_contracts-0.2.0 → colca_data_contracts-0.3.0}/src/colca_data_contracts/data_models/machinestate.yaml +0 -0
  17. {colca_data_contracts-0.2.0 → colca_data_contracts-0.3.0}/src/colca_data_contracts/data_models/oee_producer.yaml +0 -0
  18. {colca_data_contracts-0.2.0 → colca_data_contracts-0.3.0}/src/colca_data_contracts/data_models/oee_result.yaml +0 -0
  19. {colca_data_contracts-0.2.0 → colca_data_contracts-0.3.0}/src/colca_data_contracts/data_models/packmlmachine.yaml +0 -0
  20. {colca_data_contracts-0.2.0 → colca_data_contracts-0.3.0}/src/colca_data_contracts/icons.py +0 -0
  21. {colca_data_contracts-0.2.0 → colca_data_contracts-0.3.0}/src/colca_data_contracts/local_service.py +0 -0
  22. {colca_data_contracts-0.2.0 → colca_data_contracts-0.3.0}/src/colca_data_contracts/logging.py +0 -0
  23. {colca_data_contracts-0.2.0 → colca_data_contracts-0.3.0}/src/colca_data_contracts/machine_state.py +0 -0
  24. {colca_data_contracts-0.2.0 → colca_data_contracts-0.3.0}/src/colca_data_contracts/metadata_types/platform.yaml +0 -0
  25. {colca_data_contracts-0.2.0 → colca_data_contracts-0.3.0}/src/colca_data_contracts/observability.py +0 -0
  26. {colca_data_contracts-0.2.0 → colca_data_contracts-0.3.0}/src/colca_data_contracts/platform_tags.py +0 -0
  27. {colca_data_contracts-0.2.0 → colca_data_contracts-0.3.0}/src/colca_data_contracts/py.typed +0 -0
  28. {colca_data_contracts-0.2.0 → colca_data_contracts-0.3.0}/src/colca_data_contracts/quantities.py +0 -0
  29. {colca_data_contracts-0.2.0 → colca_data_contracts-0.3.0}/src/colca_data_contracts/root.py +0 -0
  30. {colca_data_contracts-0.2.0 → colca_data_contracts-0.3.0}/src/colca_data_contracts/semantic_tags/platform.yaml +0 -0
  31. {colca_data_contracts-0.2.0 → colca_data_contracts-0.3.0}/src/colca_data_contracts/semantic_types.py +0 -0
  32. {colca_data_contracts-0.2.0 → colca_data_contracts-0.3.0}/src/colca_data_contracts/service_topics.py +0 -0
  33. {colca_data_contracts-0.2.0 → colca_data_contracts-0.3.0}/src/colca_data_contracts/topics.py +0 -0
  34. {colca_data_contracts-0.2.0 → colca_data_contracts-0.3.0}/src/colca_data_contracts/vectors/annotation_id.json +0 -0
  35. {colca_data_contracts-0.2.0 → colca_data_contracts-0.3.0}/src/colca_data_contracts/vectors/authz_objects.json +0 -0
  36. {colca_data_contracts-0.2.0 → colca_data_contracts-0.3.0}/src/colca_data_contracts/vectors/data_model_vocabulary.json +0 -0
  37. {colca_data_contracts-0.2.0 → colca_data_contracts-0.3.0}/src/colca_data_contracts/vectors/grant_evaluation.json +0 -0
  38. {colca_data_contracts-0.2.0 → colca_data_contracts-0.3.0}/src/colca_data_contracts/vectors/log_payload.json +0 -0
  39. {colca_data_contracts-0.2.0 → colca_data_contracts-0.3.0}/src/colca_data_contracts/vectors/manifest_streams.json +0 -0
  40. {colca_data_contracts-0.2.0 → colca_data_contracts-0.3.0}/src/colca_data_contracts/vectors/metric_rows.json +0 -0
  41. {colca_data_contracts-0.2.0 → colca_data_contracts-0.3.0}/src/colca_data_contracts/vectors/replication_policy.json +0 -0
  42. {colca_data_contracts-0.2.0 → colca_data_contracts-0.3.0}/src/colca_data_contracts/vectors/sanitize.json +0 -0
  43. {colca_data_contracts-0.2.0 → colca_data_contracts-0.3.0}/src/colca_data_contracts/vectors/service_context.json +0 -0
  44. {colca_data_contracts-0.2.0 → colca_data_contracts-0.3.0}/src/colca_data_contracts/vectors/signal_data_types.json +0 -0
  45. {colca_data_contracts-0.2.0 → colca_data_contracts-0.3.0}/src/colca_data_contracts/vectors/topic_transformations.json +0 -0
  46. {colca_data_contracts-0.2.0 → colca_data_contracts-0.3.0}/src/colca_data_contracts.egg-info/SOURCES.txt +0 -0
  47. {colca_data_contracts-0.2.0 → colca_data_contracts-0.3.0}/src/colca_data_contracts.egg-info/dependency_links.txt +0 -0
  48. {colca_data_contracts-0.2.0 → colca_data_contracts-0.3.0}/src/colca_data_contracts.egg-info/requires.txt +0 -0
  49. {colca_data_contracts-0.2.0 → colca_data_contracts-0.3.0}/src/colca_data_contracts.egg-info/top_level.txt +0 -0
  50. {colca_data_contracts-0.2.0 → colca_data_contracts-0.3.0}/tests/test_annotation_payload.py +0 -0
  51. {colca_data_contracts-0.2.0 → colca_data_contracts-0.3.0}/tests/test_data_models_builtin.py +0 -0
  52. {colca_data_contracts-0.2.0 → colca_data_contracts-0.3.0}/tests/test_data_models_loader.py +0 -0
  53. {colca_data_contracts-0.2.0 → colca_data_contracts-0.3.0}/tests/test_data_models_vocabulary.py +0 -0
  54. {colca_data_contracts-0.2.0 → colca_data_contracts-0.3.0}/tests/test_local_service.py +0 -0
  55. {colca_data_contracts-0.2.0 → colca_data_contracts-0.3.0}/tests/test_log_payload_vector.py +0 -0
  56. {colca_data_contracts-0.2.0 → colca_data_contracts-0.3.0}/tests/test_machine_state.py +0 -0
  57. {colca_data_contracts-0.2.0 → colca_data_contracts-0.3.0}/tests/test_metric_value_types.py +0 -0
  58. {colca_data_contracts-0.2.0 → colca_data_contracts-0.3.0}/tests/test_observability.py +0 -0
  59. {colca_data_contracts-0.2.0 → colca_data_contracts-0.3.0}/tests/test_platform_tags.py +0 -0
  60. {colca_data_contracts-0.2.0 → colca_data_contracts-0.3.0}/tests/test_projection_payloads.py +0 -0
  61. {colca_data_contracts-0.2.0 → colca_data_contracts-0.3.0}/tests/test_quantities.py +0 -0
  62. {colca_data_contracts-0.2.0 → colca_data_contracts-0.3.0}/tests/test_replication_policy.py +0 -0
  63. {colca_data_contracts-0.2.0 → colca_data_contracts-0.3.0}/tests/test_signal_data_type.py +0 -0
  64. {colca_data_contracts-0.2.0 → colca_data_contracts-0.3.0}/tests/test_topic_root.py +0 -0
  65. {colca_data_contracts-0.2.0 → colca_data_contracts-0.3.0}/tests/test_topic_vectors.py +0 -0
  66. {colca_data_contracts-0.2.0 → colca_data_contracts-0.3.0}/tests/test_topology_payloads.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: colca-data-contracts
3
- Version: 0.2.0
3
+ Version: 0.3.0
4
4
  Summary: Payload contracts, topic helpers and golden vectors for Colca
5
5
  Author: Colca contributors
6
6
  License: FSL-1.1-ALv2
@@ -5,7 +5,7 @@ build-backend = "setuptools.build_meta"
5
5
  [project]
6
6
  name = "colca-data-contracts"
7
7
  # Release builds take the version from the git tag.
8
- version = "0.2.0"
8
+ version = "0.3.0"
9
9
  description = "Payload contracts, topic helpers and golden vectors for Colca"
10
10
  readme = "README.md"
11
11
  license = { text = "FSL-1.1-ALv2" }
@@ -70,7 +70,10 @@ from colca_data_contracts.payload import (
70
70
  ActorKind,
71
71
  AlarmNotificationConfigSnapshot,
72
72
  AlarmNotificationSummary,
73
+ AlarmSeverity,
74
+ AlarmState,
73
75
  AlarmStateChange,
76
+ AlarmStatus,
74
77
  AuditAction,
75
78
  AuditEvent,
76
79
  AuditOutcome,
@@ -203,6 +206,10 @@ __all__ = [ # noqa: RUF022 - grouped by topic
203
206
  "AlarmNotificationSummary",
204
207
  "AlarmStateChange",
205
208
  "NotificationDispatched",
209
+ # The standing alarm, one retained record per alarm definition
210
+ "AlarmState",
211
+ "AlarmStatus",
212
+ "AlarmSeverity",
206
213
  # Annotation instances, on their own stream like alarms
207
214
  "AnnotationPayload",
208
215
  "derive_annotation_id",
@@ -131,6 +131,21 @@ class ActorKind(BaseStrEnum):
131
131
  ANONYMOUS = "anonymous"
132
132
 
133
133
 
134
+ class AlarmStatus(BaseStrEnum):
135
+ """What a standing alarm is doing. No ``normal`` and no ``recovered``: an
136
+ alarm that no longer stands is deleted, not set to a quiet status."""
137
+
138
+ PENDING = "pending"
139
+ FIRING = "firing"
140
+ UNKNOWN = "unknown"
141
+
142
+
143
+ class AlarmSeverity(BaseStrEnum):
144
+ INFO = "info"
145
+ WARNING = "warning"
146
+ CRITICAL = "critical"
147
+
148
+
134
149
  class CustomEncoder(json.JSONEncoder):
135
150
  def default(self, obj):
136
151
  if isinstance(
@@ -144,6 +159,8 @@ class CustomEncoder(json.JSONEncoder):
144
159
  AuditAction,
145
160
  AuditOutcome,
146
161
  ActorKind,
162
+ AlarmStatus,
163
+ AlarmSeverity,
147
164
  ),
148
165
  ):
149
166
  return str(obj)
@@ -480,6 +497,55 @@ class NotificationDispatched(Payload):
480
497
  terminal: bool = True
481
498
 
482
499
 
500
+ @dataclass
501
+ class AlarmState(Payload):
502
+ """The alarm that stands right now: one record per alarm definition.
503
+
504
+ State, not an event. A new record replaces the one before it, and an empty
505
+ payload retires the path — that tombstone is "the alarm has gone". Nothing
506
+ else says so: there is no ``normal`` status, because what is not in the
507
+ key-value view is not standing. The transitions themselves stay events, on
508
+ the ``alarms`` stream as ``_AlarmStateChange``.
509
+
510
+ The record sits at the alarm's own element path,
511
+ ``{root}/v1/_AlarmState/{node}/{element-path}/{alarm-name}``, so read
512
+ grants and zones reach it like any signal. ``_Alarm`` stays reserved for
513
+ the definition, which has another writer.
514
+
515
+ Recipients are deliberately absent: another writer owns them, they are
516
+ personal data that does not belong in the retained view, and a policy
517
+ change must never rewrite an alarm's state. Who was reached and when is in
518
+ ``_NotificationDispatched``. No title or message either: the name comes
519
+ from the alarm's ``_SystemElement``, and the topic path is the link.
520
+ """
521
+
522
+ alarm_id: ULID
523
+ status: AlarmStatus
524
+ severity: AlarmSeverity
525
+ #: Unix seconds this status has held since.
526
+ since: float
527
+ signal_id: ULID
528
+ #: Why it stands. ``threshold``, ``no_data`` and ``stream_gap`` are the
529
+ #: known values, but the vocabulary stays open so a derived diagnosis can
530
+ #: name its own reason.
531
+ reason: str
532
+ #: The measurement that brought it here, with the rule's operator and
533
+ #: threshold, so a reader can render "82.4 > 80" without the definition.
534
+ value: Any | None = None
535
+ op: str | None = None
536
+ threshold: float | None = None
537
+ #: The `_AlarmStateChange` this state came out of.
538
+ event_id: str | None = None
539
+ #: ``sub`` of the person, as the node witnessed it (the command's
540
+ #: actor_id), never self-asserted.
541
+ acknowledged_by: str | None = None
542
+ acknowledged_at: float | None = None
543
+ note: str | None = None
544
+ silenced_by: str | None = None
545
+ #: Unix seconds.
546
+ silenced_until: float | None = None
547
+
548
+
483
549
  def derive_annotation_id(
484
550
  annotation_type_id: str,
485
551
  source: str,
@@ -33,6 +33,9 @@ CLASS_TABLE: dict[str, str] = {
33
33
  "_ExternalReference": "entity",
34
34
  "_AlarmNotificationConfig": "entity",
35
35
  "_NotificationConfigStatus": "entity",
36
+ # The standing alarm, one record per definition: it overwrites itself and
37
+ # a tombstone retires it, so a new subscriber sees what stands.
38
+ "_AlarmState": "entity",
36
39
  # Alarm events: append-only, not in KV, not retained, and on their own
37
40
  # stream so they never queue behind a metrics backlog.
38
41
  "_AlarmStateChange": "alarm",
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: colca-data-contracts
3
- Version: 0.2.0
3
+ Version: 0.3.0
4
4
  Summary: Payload contracts, topic helpers and golden vectors for Colca
5
5
  Author: Colca contributors
6
6
  License: FSL-1.1-ALv2
@@ -3,7 +3,10 @@ from franzmq import Topic
3
3
  from colca_data_contracts import (
4
4
  AlarmNotificationConfigSnapshot,
5
5
  AlarmNotificationSummary,
6
+ AlarmSeverity,
7
+ AlarmState,
6
8
  AlarmStateChange,
9
+ AlarmStatus,
7
10
  NotificationChannelConfig,
8
11
  NotificationChannelOutcome,
9
12
  NotificationConfigStatus,
@@ -245,3 +248,76 @@ def test_dispatch_round_trip_includes_provider_delivery_result():
245
248
 
246
249
  def test_service_types_include_notifications():
247
250
  assert ServiceType.NOTIFICATIONS == "notifications"
251
+
252
+
253
+ def test_standing_alarm_is_one_retained_record_at_the_alarm_element():
254
+ state = AlarmState(
255
+ alarm_id="01H0000000000000000000ARM1",
256
+ status=AlarmStatus.FIRING,
257
+ severity=AlarmSeverity.CRITICAL,
258
+ since=1710000000.0,
259
+ signal_id="01H0000000000000000000SGN2",
260
+ reason="threshold",
261
+ value=82.4,
262
+ op=">",
263
+ threshold=80.0,
264
+ event_id="evt-1",
265
+ )
266
+ topic = Topic(payload_type=AlarmState, node_id="n-edge1", context=("line1", "press3", "overheat"))
267
+
268
+ assert str(topic) == "colca/v1/_AlarmState/n-edge1/line1/press3/overheat"
269
+ assert AlarmState.get_identifier() == "_AlarmState"
270
+ # The alarm is an element in the tree, not a reserved _colca path, so read
271
+ # grants and zones reach it like any signal.
272
+ assert "_colca" not in str(topic)
273
+
274
+ decoded = AlarmState.decode(state.encode(), timestamp=0)
275
+
276
+ assert decoded.status == "firing"
277
+ assert decoded.severity == "critical"
278
+ assert decoded.value == 82.4
279
+ assert decoded.op == ">"
280
+ assert decoded.threshold == 80.0
281
+ assert decoded.event_id == "evt-1"
282
+
283
+
284
+ def test_standing_alarm_carries_no_recipients_and_no_presentation():
285
+ fields = set(AlarmState.__dataclass_fields__)
286
+
287
+ # Recipients have another writer, are personal data, and a policy change
288
+ # must not rewrite an alarm's state; who was reached is _NotificationDispatched.
289
+ assert not fields & {"recipients", "channels", "policies", "policy_id", "recipient_id"}
290
+ # The name comes from the alarm's _SystemElement, the link is the topic.
291
+ assert not fields & {"title", "message", "deepLink", "deep_link"}
292
+
293
+
294
+ def test_standing_alarm_has_no_normal_status():
295
+ # "Gone" is the tombstone, not a status: what is not in the KV view is not
296
+ # standing.
297
+ assert {str(member) for member in AlarmStatus} == {"pending", "firing", "unknown"}
298
+ assert {str(member) for member in AlarmSeverity} == {"info", "warning", "critical"}
299
+
300
+
301
+ def test_acknowledgement_is_witnessed_by_the_node():
302
+ state = AlarmState(
303
+ alarm_id="01H0000000000000000000ARM1",
304
+ status=AlarmStatus.FIRING,
305
+ severity=AlarmSeverity.WARNING,
306
+ since=1710000000.0,
307
+ signal_id="01H0000000000000000000SGN2",
308
+ reason="no_data",
309
+ acknowledged_by="keycloak-sub-1",
310
+ acknowledged_at=1710000060.0,
311
+ note="Sensor wird getauscht",
312
+ silenced_by="keycloak-sub-1",
313
+ silenced_until=1710003600.0,
314
+ )
315
+
316
+ decoded = AlarmState.decode(state.encode(), timestamp=0)
317
+
318
+ # The evaluator writes the actor_id the node witnessed, never a claim the
319
+ # payload made about itself.
320
+ assert decoded.acknowledged_by == "keycloak-sub-1"
321
+ assert decoded.acknowledged_at == 1710000060.0
322
+ assert decoded.note == "Sensor wird getauscht"
323
+ assert decoded.silenced_until == 1710003600.0
@@ -305,6 +305,48 @@ def test_alarm_notification_contracts_have_revised_direction_and_shape():
305
305
  assert {"revision", "notification"} <= set(event["schema"]["required"])
306
306
 
307
307
 
308
+ def test_standing_alarm_is_retained_state_with_a_closed_status_vocabulary():
309
+ body, _ = gb.build_bundle()
310
+ standing = body["contracts"]["_AlarmState"]
311
+ schema = standing["schema"]
312
+
313
+ # One record per alarm definition: it overwrites itself, and the tombstone
314
+ # is how an alarm goes away.
315
+ assert standing["class"] == "entity"
316
+ assert standing["tombstone"] is True
317
+ assert set(schema["required"]) == {
318
+ "alarm_id",
319
+ "status",
320
+ "severity",
321
+ "since",
322
+ "signal_id",
323
+ "reason",
324
+ }
325
+ # Status and severity are closed; reason stays open so a derived diagnosis
326
+ # can name its own.
327
+ assert schema["properties"]["status"]["enum"] == ["pending", "firing", "unknown"]
328
+ assert schema["properties"]["severity"]["enum"] == ["info", "warning", "critical"]
329
+ assert "enum" not in schema["properties"]["reason"]
330
+
331
+ validator = jsonschema.Draft202012Validator(schema)
332
+ standing_now = {
333
+ "alarm_id": "01H0000000000000000000ARM1",
334
+ "status": "firing",
335
+ "severity": "critical",
336
+ "since": 1710000000.0,
337
+ "signal_id": "01H0000000000000000000SGN2",
338
+ "reason": "threshold",
339
+ "value": 82.4,
340
+ "op": ">",
341
+ "threshold": 80.0,
342
+ }
343
+ validator.validate(standing_now)
344
+ # There is no "normal": an alarm that no longer stands is deleted.
345
+ assert not validator.is_valid({**standing_now, "status": "normal"})
346
+ assert not validator.is_valid({**standing_now, "status": "recovered"})
347
+ assert validator.is_valid({**standing_now, "reason": "compressor_stall"})
348
+
349
+
308
350
  def test_alarm_channel_schema_requires_sealed_secret_and_rejects_cleartext_fields():
309
351
  body, _ = gb.build_bundle()
310
352
  channel = body["contracts"]["_AlarmNotificationConfig"]["schema"]["properties"]["channels"]["items"]
@@ -405,6 +447,7 @@ ULID_CONSTRAINED_FIELDS = {
405
447
  "_Constant": ["id", "system_element_id", "semantic_type_id"],
406
448
  "_Resource": ["id", "system_element_id"],
407
449
  "_ExternalReference": ["id", "external_system_id"],
450
+ "_AlarmState": ["alarm_id", "signal_id"],
408
451
  # annotation_type_id stays unconstrained until the golden annotation-id
409
452
  # vectors derive from ULID type ids — see payload.Annotation.
410
453
  "_Annotation": ["annotation_id", "signal_ids/items"],