omnibase_infra 0.2.5__py3-none-any.whl → 0.2.7__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (139) hide show
  1. omnibase_infra/constants_topic_patterns.py +26 -0
  2. omnibase_infra/enums/__init__.py +3 -0
  3. omnibase_infra/enums/enum_consumer_group_purpose.py +92 -0
  4. omnibase_infra/enums/enum_handler_source_mode.py +16 -2
  5. omnibase_infra/errors/__init__.py +4 -0
  6. omnibase_infra/errors/error_binding_resolution.py +128 -0
  7. omnibase_infra/event_bus/configs/kafka_event_bus_config.yaml +0 -2
  8. omnibase_infra/event_bus/event_bus_inmemory.py +64 -10
  9. omnibase_infra/event_bus/event_bus_kafka.py +105 -47
  10. omnibase_infra/event_bus/mixin_kafka_broadcast.py +3 -7
  11. omnibase_infra/event_bus/mixin_kafka_dlq.py +12 -6
  12. omnibase_infra/event_bus/models/config/model_kafka_event_bus_config.py +0 -81
  13. omnibase_infra/event_bus/testing/__init__.py +26 -0
  14. omnibase_infra/event_bus/testing/adapter_protocol_event_publisher_inmemory.py +418 -0
  15. omnibase_infra/event_bus/testing/model_publisher_metrics.py +64 -0
  16. omnibase_infra/handlers/handler_consul.py +2 -0
  17. omnibase_infra/handlers/mixins/__init__.py +5 -0
  18. omnibase_infra/handlers/mixins/mixin_consul_service.py +274 -10
  19. omnibase_infra/handlers/mixins/mixin_consul_topic_index.py +585 -0
  20. omnibase_infra/handlers/models/model_filesystem_config.py +4 -4
  21. omnibase_infra/migrations/001_create_event_ledger.sql +166 -0
  22. omnibase_infra/migrations/001_drop_event_ledger.sql +18 -0
  23. omnibase_infra/mixins/mixin_node_introspection.py +189 -19
  24. omnibase_infra/models/__init__.py +8 -0
  25. omnibase_infra/models/bindings/__init__.py +59 -0
  26. omnibase_infra/models/bindings/constants.py +144 -0
  27. omnibase_infra/models/bindings/model_binding_resolution_result.py +103 -0
  28. omnibase_infra/models/bindings/model_operation_binding.py +44 -0
  29. omnibase_infra/models/bindings/model_operation_bindings_subcontract.py +152 -0
  30. omnibase_infra/models/bindings/model_parsed_binding.py +52 -0
  31. omnibase_infra/models/discovery/model_introspection_config.py +25 -17
  32. omnibase_infra/models/dispatch/__init__.py +8 -0
  33. omnibase_infra/models/dispatch/model_debug_trace_snapshot.py +114 -0
  34. omnibase_infra/models/dispatch/model_materialized_dispatch.py +141 -0
  35. omnibase_infra/models/handlers/model_handler_source_config.py +1 -1
  36. omnibase_infra/models/model_node_identity.py +126 -0
  37. omnibase_infra/models/projection/model_snapshot_topic_config.py +3 -2
  38. omnibase_infra/models/registration/__init__.py +9 -0
  39. omnibase_infra/models/registration/model_event_bus_topic_entry.py +59 -0
  40. omnibase_infra/models/registration/model_node_event_bus_config.py +99 -0
  41. omnibase_infra/models/registration/model_node_introspection_event.py +11 -0
  42. omnibase_infra/models/runtime/__init__.py +9 -0
  43. omnibase_infra/models/validation/model_coverage_metrics.py +2 -2
  44. omnibase_infra/nodes/__init__.py +9 -0
  45. omnibase_infra/nodes/contract_registry_reducer/__init__.py +29 -0
  46. omnibase_infra/nodes/contract_registry_reducer/contract.yaml +255 -0
  47. omnibase_infra/nodes/contract_registry_reducer/models/__init__.py +38 -0
  48. omnibase_infra/nodes/contract_registry_reducer/models/model_contract_registry_state.py +266 -0
  49. omnibase_infra/nodes/contract_registry_reducer/models/model_payload_cleanup_topic_references.py +55 -0
  50. omnibase_infra/nodes/contract_registry_reducer/models/model_payload_deactivate_contract.py +58 -0
  51. omnibase_infra/nodes/contract_registry_reducer/models/model_payload_mark_stale.py +49 -0
  52. omnibase_infra/nodes/contract_registry_reducer/models/model_payload_update_heartbeat.py +71 -0
  53. omnibase_infra/nodes/contract_registry_reducer/models/model_payload_update_topic.py +66 -0
  54. omnibase_infra/nodes/contract_registry_reducer/models/model_payload_upsert_contract.py +92 -0
  55. omnibase_infra/nodes/contract_registry_reducer/node.py +121 -0
  56. omnibase_infra/nodes/contract_registry_reducer/reducer.py +784 -0
  57. omnibase_infra/nodes/contract_registry_reducer/registry/__init__.py +9 -0
  58. omnibase_infra/nodes/contract_registry_reducer/registry/registry_infra_contract_registry_reducer.py +101 -0
  59. omnibase_infra/nodes/handlers/consul/contract.yaml +85 -0
  60. omnibase_infra/nodes/handlers/db/contract.yaml +72 -0
  61. omnibase_infra/nodes/handlers/graph/contract.yaml +127 -0
  62. omnibase_infra/nodes/handlers/http/contract.yaml +74 -0
  63. omnibase_infra/nodes/handlers/intent/contract.yaml +66 -0
  64. omnibase_infra/nodes/handlers/mcp/contract.yaml +69 -0
  65. omnibase_infra/nodes/handlers/vault/contract.yaml +91 -0
  66. omnibase_infra/nodes/node_ledger_projection_compute/__init__.py +50 -0
  67. omnibase_infra/nodes/node_ledger_projection_compute/contract.yaml +104 -0
  68. omnibase_infra/nodes/node_ledger_projection_compute/node.py +284 -0
  69. omnibase_infra/nodes/node_ledger_projection_compute/registry/__init__.py +29 -0
  70. omnibase_infra/nodes/node_ledger_projection_compute/registry/registry_infra_ledger_projection.py +118 -0
  71. omnibase_infra/nodes/node_ledger_write_effect/__init__.py +82 -0
  72. omnibase_infra/nodes/node_ledger_write_effect/contract.yaml +200 -0
  73. omnibase_infra/nodes/node_ledger_write_effect/handlers/__init__.py +22 -0
  74. omnibase_infra/nodes/node_ledger_write_effect/handlers/handler_ledger_append.py +372 -0
  75. omnibase_infra/nodes/node_ledger_write_effect/handlers/handler_ledger_query.py +597 -0
  76. omnibase_infra/nodes/node_ledger_write_effect/models/__init__.py +31 -0
  77. omnibase_infra/nodes/node_ledger_write_effect/models/model_ledger_append_result.py +54 -0
  78. omnibase_infra/nodes/node_ledger_write_effect/models/model_ledger_entry.py +92 -0
  79. omnibase_infra/nodes/node_ledger_write_effect/models/model_ledger_query.py +53 -0
  80. omnibase_infra/nodes/node_ledger_write_effect/models/model_ledger_query_result.py +41 -0
  81. omnibase_infra/nodes/node_ledger_write_effect/node.py +89 -0
  82. omnibase_infra/nodes/node_ledger_write_effect/protocols/__init__.py +13 -0
  83. omnibase_infra/nodes/node_ledger_write_effect/protocols/protocol_ledger_persistence.py +127 -0
  84. omnibase_infra/nodes/node_ledger_write_effect/registry/__init__.py +9 -0
  85. omnibase_infra/nodes/node_ledger_write_effect/registry/registry_infra_ledger_write.py +121 -0
  86. omnibase_infra/nodes/node_registration_orchestrator/registry/registry_infra_node_registration_orchestrator.py +7 -5
  87. omnibase_infra/nodes/reducers/models/__init__.py +7 -2
  88. omnibase_infra/nodes/reducers/models/model_payload_consul_register.py +11 -0
  89. omnibase_infra/nodes/reducers/models/model_payload_ledger_append.py +133 -0
  90. omnibase_infra/nodes/reducers/registration_reducer.py +1 -0
  91. omnibase_infra/protocols/__init__.py +3 -0
  92. omnibase_infra/protocols/protocol_dispatch_engine.py +152 -0
  93. omnibase_infra/runtime/__init__.py +60 -0
  94. omnibase_infra/runtime/binding_resolver.py +753 -0
  95. omnibase_infra/runtime/constants_security.py +70 -0
  96. omnibase_infra/runtime/contract_loaders/__init__.py +9 -0
  97. omnibase_infra/runtime/contract_loaders/operation_bindings_loader.py +789 -0
  98. omnibase_infra/runtime/emit_daemon/__init__.py +97 -0
  99. omnibase_infra/runtime/emit_daemon/cli.py +844 -0
  100. omnibase_infra/runtime/emit_daemon/client.py +811 -0
  101. omnibase_infra/runtime/emit_daemon/config.py +535 -0
  102. omnibase_infra/runtime/emit_daemon/daemon.py +812 -0
  103. omnibase_infra/runtime/emit_daemon/event_registry.py +477 -0
  104. omnibase_infra/runtime/emit_daemon/model_daemon_request.py +139 -0
  105. omnibase_infra/runtime/emit_daemon/model_daemon_response.py +191 -0
  106. omnibase_infra/runtime/emit_daemon/queue.py +618 -0
  107. omnibase_infra/runtime/event_bus_subcontract_wiring.py +466 -0
  108. omnibase_infra/runtime/handler_source_resolver.py +43 -2
  109. omnibase_infra/runtime/kafka_contract_source.py +984 -0
  110. omnibase_infra/runtime/models/__init__.py +13 -0
  111. omnibase_infra/runtime/models/model_contract_load_result.py +224 -0
  112. omnibase_infra/runtime/models/model_runtime_contract_config.py +268 -0
  113. omnibase_infra/runtime/models/model_runtime_scheduler_config.py +4 -3
  114. omnibase_infra/runtime/models/model_security_config.py +109 -0
  115. omnibase_infra/runtime/publisher_topic_scoped.py +294 -0
  116. omnibase_infra/runtime/runtime_contract_config_loader.py +406 -0
  117. omnibase_infra/runtime/service_kernel.py +76 -6
  118. omnibase_infra/runtime/service_message_dispatch_engine.py +558 -15
  119. omnibase_infra/runtime/service_runtime_host_process.py +770 -20
  120. omnibase_infra/runtime/transition_notification_publisher.py +3 -2
  121. omnibase_infra/runtime/util_wiring.py +206 -62
  122. omnibase_infra/services/mcp/service_mcp_tool_sync.py +27 -9
  123. omnibase_infra/services/session/config_consumer.py +25 -8
  124. omnibase_infra/services/session/config_store.py +2 -2
  125. omnibase_infra/services/session/consumer.py +1 -1
  126. omnibase_infra/topics/__init__.py +45 -0
  127. omnibase_infra/topics/platform_topic_suffixes.py +140 -0
  128. omnibase_infra/topics/util_topic_composition.py +95 -0
  129. omnibase_infra/types/typed_dict/__init__.py +9 -1
  130. omnibase_infra/types/typed_dict/typed_dict_envelope_build_params.py +115 -0
  131. omnibase_infra/utils/__init__.py +9 -0
  132. omnibase_infra/utils/util_consumer_group.py +232 -0
  133. omnibase_infra/validation/infra_validators.py +18 -1
  134. omnibase_infra/validation/validation_exemptions.yaml +192 -0
  135. {omnibase_infra-0.2.5.dist-info → omnibase_infra-0.2.7.dist-info}/METADATA +3 -3
  136. {omnibase_infra-0.2.5.dist-info → omnibase_infra-0.2.7.dist-info}/RECORD +139 -52
  137. {omnibase_infra-0.2.5.dist-info → omnibase_infra-0.2.7.dist-info}/entry_points.txt +1 -0
  138. {omnibase_infra-0.2.5.dist-info → omnibase_infra-0.2.7.dist-info}/WHEEL +0 -0
  139. {omnibase_infra-0.2.5.dist-info → omnibase_infra-0.2.7.dist-info}/licenses/LICENSE +0 -0
@@ -0,0 +1,294 @@
1
+ # SPDX-License-Identifier: MIT
2
+ # Copyright (c) 2025 OmniNode Team
3
+ """Topic-scoped publisher that validates against contract-declared publish topics.
4
+
5
+ This module implements a publisher that enforces topic-level access control
6
+ based on the contract's `event_bus.publish_topics` section. Handlers can only
7
+ publish to topics explicitly declared in their contract, preventing unauthorized
8
+ event emission and maintaining clean architectural boundaries.
9
+
10
+ Design Principles:
11
+ - **Contract-Driven Access Control**: Topics must be declared in contract
12
+ - **Environment-Aware Routing**: Topic suffixes are prefixed with environment
13
+ - **Fail-Fast Validation**: Invalid topics raise immediately, not at delivery
14
+ - **Duck-Typed Protocol**: Implements publisher protocol without explicit inheritance
15
+
16
+ Architecture Context:
17
+ In the ONEX handler architecture, each handler receives a topic-scoped
18
+ publisher configured with only the topics from its contract. This ensures:
19
+
20
+ 1. Handlers cannot publish to arbitrary topics
21
+ 2. Topic dependencies are explicit and auditable
22
+ 3. Contract changes required to add new publish targets
23
+ 4. Clear separation between handler capabilities
24
+
25
+ Example Usage:
26
+ ```python
27
+ from omnibase_infra.runtime.publisher_topic_scoped import PublisherTopicScoped
28
+ from omnibase_infra.event_bus.event_bus_kafka import EventBusKafka
29
+
30
+ # Create topic-scoped publisher from contract
31
+ publisher = PublisherTopicScoped(
32
+ event_bus=kafka_event_bus,
33
+ allowed_topics={"onex.fsm.state.transitions.v1", "onex.events.v1"},
34
+ environment="dev",
35
+ )
36
+
37
+ # Publish to allowed topic (succeeds)
38
+ await publisher.publish(
39
+ event_type="state.transition",
40
+ payload={"from": "pending", "to": "active"},
41
+ topic="onex.fsm.state.transitions.v1",
42
+ correlation_id="abc-123",
43
+ )
44
+
45
+ # Publish to disallowed topic (raises ProtocolConfigurationError)
46
+ await publisher.publish(
47
+ event_type="audit.log",
48
+ payload={"action": "login"},
49
+ topic="onex.audit.v1", # Not in allowed_topics
50
+ correlation_id="xyz-789",
51
+ )
52
+ # ProtocolConfigurationError: Topic 'onex.audit.v1' not in contract's publish_topics.
53
+ # Allowed: ['onex.events.v1', 'onex.fsm.state.transitions.v1']
54
+ ```
55
+
56
+ Thread Safety:
57
+ This class is coroutine-safe for concurrent async publishing. The underlying
58
+ event bus handles synchronization. No mutable state is shared between
59
+ publish operations.
60
+
61
+ Related Tickets:
62
+ - OMN-1621: Runtime consumes event_bus subcontract for contract-driven topic wiring
63
+
64
+ See Also:
65
+ - ProtocolEventBusLike: Event bus protocol
66
+ - ModelKafkaEventBusConfig: Kafka configuration model
67
+ - EventBusKafka: Production Kafka event bus implementation
68
+ """
69
+
70
+ from __future__ import annotations
71
+
72
+ import json
73
+ import logging
74
+ from typing import TYPE_CHECKING
75
+ from uuid import UUID
76
+
77
+ from omnibase_core.types import JsonType
78
+ from omnibase_infra.errors import ProtocolConfigurationError
79
+ from omnibase_infra.protocols.protocol_event_bus_like import ProtocolEventBusLike
80
+
81
+ if TYPE_CHECKING:
82
+ from omnibase_infra.event_bus.event_bus_inmemory import EventBusInmemory
83
+ from omnibase_infra.event_bus.event_bus_kafka import EventBusKafka
84
+
85
+ logger = logging.getLogger(__name__)
86
+
87
+
88
+ class PublisherTopicScoped:
89
+ """Publisher that validates against allowed topics from contract.
90
+
91
+ This publisher ensures handlers can only publish to topics explicitly
92
+ declared in their contract's event_bus.publish_topics section.
93
+ Implements a publisher protocol via duck typing (no explicit inheritance
94
+ required per ONEX conventions).
95
+
96
+ Features:
97
+ - Contract-driven topic access control
98
+ - Environment-aware topic resolution
99
+ - Fail-fast validation on disallowed topics
100
+ - JSON serialization for payloads
101
+ - Correlation ID propagation for distributed tracing
102
+
103
+ Attributes:
104
+ _event_bus: The underlying event bus for publishing
105
+ _allowed_topics: Set of topic suffixes allowed by contract
106
+ _environment: Environment prefix for topic resolution
107
+
108
+ Example:
109
+ >>> publisher = PublisherTopicScoped(
110
+ ... event_bus=kafka_bus,
111
+ ... allowed_topics={"events.v1", "commands.v1"},
112
+ ... environment="dev",
113
+ ... )
114
+ >>> await publisher.publish(
115
+ ... event_type="user.created",
116
+ ... payload={"user_id": "123"},
117
+ ... topic="events.v1",
118
+ ... )
119
+ """
120
+
121
+ def __init__(
122
+ self,
123
+ event_bus: ProtocolEventBusLike,
124
+ allowed_topics: set[str],
125
+ environment: str,
126
+ ) -> None:
127
+ """Initialize topic-scoped publisher.
128
+
129
+ Args:
130
+ event_bus: The event bus implementation for actual publishing
131
+ (EventBusKafka or EventBusInmemory).
132
+ Must implement publish(topic, key, value) method. Duck typed per ONEX.
133
+ allowed_topics: Set of topic suffixes from contract's publish_topics.
134
+ These are the ONLY topics this publisher can publish to.
135
+ environment: Environment prefix (e.g., 'dev', 'staging', 'prod').
136
+ Used to construct full topic names.
137
+
138
+ Example:
139
+ >>> publisher = PublisherTopicScoped(
140
+ ... event_bus=EventBusKafka.default(),
141
+ ... allowed_topics={"onex.events.v1"},
142
+ ... environment="dev",
143
+ ... )
144
+
145
+ Raises:
146
+ ValueError: If environment is empty or whitespace-only.
147
+ """
148
+ if not environment or not environment.strip():
149
+ raise ValueError("environment must be a non-empty string")
150
+
151
+ self._event_bus = event_bus
152
+ self._allowed_topics = frozenset(allowed_topics)
153
+ self._environment = environment
154
+ self._logger = logging.getLogger(__name__)
155
+
156
+ def _normalize_correlation_id(
157
+ self,
158
+ correlation_id: str | UUID | None,
159
+ ) -> bytes | None:
160
+ """Normalize correlation ID to bytes for Kafka message key.
161
+
162
+ Correlation IDs in ONEX can be either UUID objects (in-memory canonical)
163
+ or strings (wire canonical). Both serialize deterministically to the
164
+ same byte representation.
165
+
166
+ Args:
167
+ correlation_id: Correlation ID as string, UUID, or None.
168
+
169
+ Returns:
170
+ UTF-8 encoded bytes for use as Kafka message key, or None.
171
+ """
172
+ if correlation_id is None:
173
+ return None
174
+ return str(correlation_id).encode("utf-8")
175
+
176
+ def resolve_topic(self, topic_suffix: str) -> str:
177
+ """Resolve topic suffix to full topic name with environment prefix.
178
+
179
+ The full topic name follows the ONEX convention:
180
+ `{environment}.{topic_suffix}`
181
+
182
+ Args:
183
+ topic_suffix: ONEX format topic suffix (e.g., 'onex.events.v1')
184
+
185
+ Returns:
186
+ Full topic name with environment prefix (e.g., 'dev.onex.events.v1')
187
+
188
+ Example:
189
+ >>> publisher.resolve_topic("onex.events.v1")
190
+ 'dev.onex.events.v1'
191
+ """
192
+ return f"{self._environment}.{topic_suffix}"
193
+
194
+ async def publish(
195
+ self,
196
+ event_type: str,
197
+ payload: JsonType,
198
+ topic: str | None = None,
199
+ correlation_id: str | UUID | None = None,
200
+ **kwargs: object,
201
+ ) -> bool:
202
+ """Publish to allowed topic. Raises if topic not in contract.
203
+
204
+ Validates the topic against the contract's publish_topics whitelist,
205
+ serializes the payload to JSON, and publishes via the underlying
206
+ event bus.
207
+
208
+ Args:
209
+ event_type: Type of event being published (for logging/tracing).
210
+ payload: Event payload (must be JSON-serializable).
211
+ Accepts any JsonType: str, int, float, bool, None,
212
+ list[JsonType], or dict[str, JsonType].
213
+ topic: Topic suffix to publish to (required).
214
+ Must be in the contract's publish_topics.
215
+ correlation_id: Optional correlation ID for distributed tracing.
216
+ If provided, used as the message key for partitioning.
217
+ **kwargs: Additional keyword arguments (ignored, for protocol flexibility).
218
+
219
+ Returns:
220
+ True if publish succeeded.
221
+
222
+ Raises:
223
+ ProtocolConfigurationError: If topic is None or not in contract's publish_topics.
224
+
225
+ Example:
226
+ >>> await publisher.publish(
227
+ ... event_type="user.created",
228
+ ... payload={"user_id": "123", "name": "John"},
229
+ ... topic="onex.events.v1",
230
+ ... correlation_id="corr-abc-123",
231
+ ... )
232
+ True
233
+ """
234
+ if topic is None:
235
+ raise ProtocolConfigurationError(
236
+ "topic is required for PublisherTopicScoped"
237
+ )
238
+
239
+ if topic not in self._allowed_topics:
240
+ raise ProtocolConfigurationError(
241
+ f"Topic '{topic}' not in contract's publish_topics. "
242
+ f"Allowed: {sorted(self._allowed_topics)}"
243
+ )
244
+
245
+ full_topic = self.resolve_topic(topic)
246
+
247
+ # Serialize payload to JSON bytes
248
+ value = json.dumps(payload).encode("utf-8")
249
+ key = self._normalize_correlation_id(correlation_id)
250
+
251
+ # Publish to event bus
252
+ await self._event_bus.publish(
253
+ topic=full_topic,
254
+ key=key,
255
+ value=value,
256
+ )
257
+
258
+ self._logger.debug(
259
+ "Published to topic=%s, event_type=%s, correlation_id=%s",
260
+ full_topic,
261
+ event_type,
262
+ correlation_id,
263
+ )
264
+
265
+ return True
266
+
267
+ @property
268
+ def allowed_topics(self) -> frozenset[str]:
269
+ """Return immutable set of allowed topics.
270
+
271
+ Returns:
272
+ Frozen set of topic suffixes allowed by this publisher.
273
+
274
+ Example:
275
+ >>> publisher.allowed_topics
276
+ frozenset({'onex.events.v1', 'onex.commands.v1'})
277
+ """
278
+ return self._allowed_topics
279
+
280
+ @property
281
+ def environment(self) -> str:
282
+ """Return the environment prefix.
283
+
284
+ Returns:
285
+ Environment string used for topic resolution.
286
+
287
+ Example:
288
+ >>> publisher.environment
289
+ 'dev'
290
+ """
291
+ return self._environment
292
+
293
+
294
+ __all__: list[str] = ["PublisherTopicScoped"]
@@ -0,0 +1,406 @@
1
+ # SPDX-License-Identifier: MIT
2
+ # Copyright (c) 2025 OmniNode Team
3
+ """Unified loader for runtime contract configuration.
4
+
5
+ This module provides the RuntimeContractConfigLoader class that scans
6
+ directories for contract.yaml files at startup and loads all subcontracts
7
+ (handler_routing, operation_bindings) into a consolidated configuration.
8
+
9
+ Part of OMN-1519: Runtime contract config loader.
10
+
11
+ Design Pattern:
12
+ RuntimeContractConfigLoader acts as an orchestrator for the individual
13
+ subcontract loaders (handler_routing_loader, operation_bindings_loader).
14
+ It scans directories, delegates loading to specialized loaders, and
15
+ aggregates results into a single ModelRuntimeContractConfig.
16
+
17
+ Error Handling:
18
+ The loader uses a graceful error handling strategy - individual contract
19
+ failures are logged and collected but do not stop the loading of other
20
+ contracts. This ensures the runtime can start even if some contracts
21
+ are malformed.
22
+
23
+ Thread Safety:
24
+ RuntimeContractConfigLoader is designed for single-threaded use during
25
+ startup. The resulting ModelRuntimeContractConfig is immutable and
26
+ thread-safe for concurrent read access.
27
+
28
+ Example:
29
+ >>> from pathlib import Path
30
+ >>> from omnibase_infra.runtime import RuntimeContractConfigLoader
31
+ >>>
32
+ >>> loader = RuntimeContractConfigLoader()
33
+ >>> config = loader.load_all_contracts(
34
+ ... search_paths=[Path("src/omnibase_infra/nodes")],
35
+ ... )
36
+ >>> print(f"Loaded {config.total_contracts_loaded} contracts")
37
+ >>> for path, routing in config.handler_routing_configs.items():
38
+ ... print(f" {path}: {len(routing.handlers)} handlers")
39
+
40
+ .. versionadded:: 0.2.8
41
+ Created as part of OMN-1519.
42
+ """
43
+
44
+ from __future__ import annotations
45
+
46
+ import logging
47
+ from pathlib import Path
48
+ from uuid import UUID, uuid4
49
+
50
+ from omnibase_infra.enums import EnumInfraTransportType
51
+ from omnibase_infra.errors import ModelInfraErrorContext, ProtocolConfigurationError
52
+ from omnibase_infra.models.bindings import ModelOperationBindingsSubcontract
53
+ from omnibase_infra.models.routing import ModelRoutingSubcontract
54
+ from omnibase_infra.runtime.contract_loaders.handler_routing_loader import (
55
+ load_handler_routing_subcontract,
56
+ )
57
+ from omnibase_infra.runtime.contract_loaders.operation_bindings_loader import (
58
+ load_operation_bindings_subcontract,
59
+ )
60
+ from omnibase_infra.runtime.models.model_contract_load_result import (
61
+ ModelContractLoadResult,
62
+ )
63
+ from omnibase_infra.runtime.models.model_runtime_contract_config import (
64
+ ModelRuntimeContractConfig,
65
+ )
66
+
67
+ logger = logging.getLogger(__name__)
68
+
69
+ # Contract file name to search for
70
+ CONTRACT_YAML_FILENAME: str = "contract.yaml"
71
+
72
+
73
+ class RuntimeContractConfigLoader:
74
+ """Unified loader for runtime contract configuration.
75
+
76
+ Scans directories for contract.yaml files and loads all subcontracts
77
+ (handler_routing, operation_bindings) at startup. Individual loaders
78
+ are delegated to for specific subcontract types.
79
+
80
+ Example:
81
+ >>> loader = RuntimeContractConfigLoader()
82
+ >>> config = loader.load_all_contracts(
83
+ ... search_paths=[Path("src/nodes")],
84
+ ... )
85
+ >>> if config.all_successful:
86
+ ... print("All contracts loaded successfully")
87
+
88
+ Note:
89
+ Namespace allowlisting for handler imports is configured at the
90
+ HandlerPluginLoader layer, not at this config loading layer.
91
+ See CLAUDE.md Handler Plugin Loader security patterns.
92
+ """
93
+
94
+ def __init__(self) -> None:
95
+ """Initialize the contract config loader.
96
+
97
+ The loader is stateless and delegates to individual subcontract
98
+ loaders for handler_routing and operation_bindings sections.
99
+ """
100
+
101
+ def load_all_contracts(
102
+ self,
103
+ search_paths: list[Path],
104
+ correlation_id: UUID | None = None,
105
+ ) -> ModelRuntimeContractConfig:
106
+ """Scan directories and load all contract.yaml files.
107
+
108
+ Recursively scans the provided directories for contract.yaml files,
109
+ loading handler_routing and operation_bindings subcontracts from each.
110
+ Errors are collected per-contract without stopping the overall load.
111
+
112
+ Args:
113
+ search_paths: Directories to scan for contract.yaml files.
114
+ correlation_id: Optional correlation ID for tracing. If not
115
+ provided, a new UUID is generated.
116
+
117
+ Returns:
118
+ ModelRuntimeContractConfig containing consolidated configuration
119
+ from all loaded contracts, including any errors encountered.
120
+
121
+ Example:
122
+ >>> config = loader.load_all_contracts(
123
+ ... search_paths=[
124
+ ... Path("src/omnibase_infra/nodes"),
125
+ ... Path("src/myapp/nodes"),
126
+ ... ],
127
+ ... )
128
+ >>> print(f"Success rate: {config.success_rate:.1%}")
129
+ """
130
+ correlation_id = correlation_id or uuid4()
131
+
132
+ logger.info(
133
+ "Starting contract config load with correlation_id=%s, search_paths=%s",
134
+ correlation_id,
135
+ [str(p) for p in search_paths],
136
+ )
137
+
138
+ # Find all contract.yaml files
139
+ contract_paths = self._scan_for_contracts(search_paths)
140
+ total_found = len(contract_paths)
141
+
142
+ logger.info(
143
+ "Found %d contract.yaml files to load (correlation_id=%s)",
144
+ total_found,
145
+ correlation_id,
146
+ )
147
+
148
+ # Load each contract
149
+ results: list[ModelContractLoadResult] = []
150
+ for contract_path in contract_paths:
151
+ result = self._load_single_contract(contract_path, correlation_id)
152
+ results.append(result)
153
+
154
+ # Calculate totals
155
+ total_loaded = sum(1 for r in results if r.success)
156
+ total_errors = sum(1 for r in results if not r.success)
157
+
158
+ # Log summary
159
+ if total_errors > 0:
160
+ logger.warning(
161
+ "Contract loading completed with errors: "
162
+ "found=%d, loaded=%d, errors=%d (correlation_id=%s)",
163
+ total_found,
164
+ total_loaded,
165
+ total_errors,
166
+ correlation_id,
167
+ )
168
+ for result in results:
169
+ if not result.success:
170
+ logger.warning(
171
+ " Failed: %s - %s",
172
+ result.contract_path,
173
+ result.error,
174
+ )
175
+ else:
176
+ logger.info(
177
+ "Contract loading completed successfully: "
178
+ "found=%d, loaded=%d (correlation_id=%s)",
179
+ total_found,
180
+ total_loaded,
181
+ correlation_id,
182
+ )
183
+
184
+ return ModelRuntimeContractConfig(
185
+ contract_results=results,
186
+ total_contracts_found=total_found,
187
+ total_contracts_loaded=total_loaded,
188
+ total_errors=total_errors,
189
+ correlation_id=correlation_id,
190
+ )
191
+
192
+ def _scan_for_contracts(self, search_paths: list[Path]) -> list[Path]:
193
+ """Find all contract.yaml files in search paths.
194
+
195
+ Recursively scans each search path for contract.yaml files.
196
+ Paths that don't exist are logged as warnings and skipped.
197
+
198
+ Args:
199
+ search_paths: Directories to scan.
200
+
201
+ Returns:
202
+ List of paths to contract.yaml files, sorted by path for
203
+ deterministic ordering.
204
+ """
205
+ contract_paths: list[Path] = []
206
+
207
+ for search_path in search_paths:
208
+ if not search_path.exists():
209
+ logger.warning(
210
+ "Search path does not exist, skipping: %s",
211
+ search_path,
212
+ )
213
+ continue
214
+
215
+ if not search_path.is_dir():
216
+ logger.warning(
217
+ "Search path is not a directory, skipping: %s",
218
+ search_path,
219
+ )
220
+ continue
221
+
222
+ # Use glob to find all contract.yaml files recursively
223
+ found = list(search_path.glob(f"**/{CONTRACT_YAML_FILENAME}"))
224
+ logger.debug(
225
+ "Found %d contract.yaml files in %s",
226
+ len(found),
227
+ search_path,
228
+ )
229
+ contract_paths.extend(found)
230
+
231
+ # Sort for deterministic ordering
232
+ return sorted(contract_paths)
233
+
234
+ def _load_single_contract(
235
+ self,
236
+ contract_path: Path,
237
+ correlation_id: UUID,
238
+ ) -> ModelContractLoadResult:
239
+ """Load handler_routing and operation_bindings from a single contract.
240
+
241
+ Attempts to load both subcontracts from the contract.yaml file.
242
+ Either or both may be missing - only present sections are loaded.
243
+ Errors from either loader are caught and reported.
244
+
245
+ Args:
246
+ contract_path: Path to the contract.yaml file.
247
+ correlation_id: Correlation ID for tracing.
248
+
249
+ Returns:
250
+ ModelContractLoadResult with loaded subcontracts or error.
251
+
252
+ Note:
253
+ Empty operation_bindings sections (present in YAML but containing
254
+ no bindings or global_bindings) are intentionally treated as "not
255
+ present" and result in operation_bindings=None. This simplifies
256
+ downstream consumers who only need to check for None rather than
257
+ also checking for empty collections. Callers cannot distinguish
258
+ between "section missing from YAML" and "section present but empty"
259
+ - both result in operation_bindings=None.
260
+ """
261
+ logger.debug(
262
+ "Loading contract: %s (correlation_id=%s)",
263
+ contract_path,
264
+ correlation_id,
265
+ )
266
+
267
+ handler_routing: ModelRoutingSubcontract | None = None
268
+ operation_bindings: ModelOperationBindingsSubcontract | None = None
269
+ errors: list[str] = []
270
+
271
+ # Try to load handler_routing
272
+ try:
273
+ handler_routing = load_handler_routing_subcontract(contract_path)
274
+ logger.debug(
275
+ "Loaded handler_routing from %s: %d handlers",
276
+ contract_path,
277
+ len(handler_routing.handlers),
278
+ )
279
+ except ProtocolConfigurationError as e:
280
+ # Check if this is "missing section" which is OK
281
+ if (
282
+ "MISSING_HANDLER_ROUTING" in str(e)
283
+ or "handler_routing" in str(e).lower()
284
+ ):
285
+ logger.debug(
286
+ "No handler_routing section in %s (this is OK)",
287
+ contract_path,
288
+ )
289
+ else:
290
+ error_msg = f"handler_routing load failed: {e}"
291
+ logger.warning(
292
+ "Failed to load handler_routing from %s: %s",
293
+ contract_path,
294
+ e,
295
+ )
296
+ errors.append(error_msg)
297
+ except Exception as e:
298
+ error_msg = f"handler_routing load failed: {type(e).__name__}: {e}"
299
+ logger.warning(
300
+ "Unexpected error loading handler_routing from %s: %s",
301
+ contract_path,
302
+ e,
303
+ )
304
+ errors.append(error_msg)
305
+
306
+ # Try to load operation_bindings
307
+ try:
308
+ operation_bindings = load_operation_bindings_subcontract(contract_path)
309
+ if operation_bindings.bindings or operation_bindings.global_bindings:
310
+ logger.debug(
311
+ "Loaded operation_bindings from %s: %d operations, %d global bindings",
312
+ contract_path,
313
+ len(operation_bindings.bindings),
314
+ len(operation_bindings.global_bindings or []),
315
+ )
316
+ else:
317
+ # NOTE: Empty operation_bindings sections (present but no bindings) are
318
+ # intentionally converted to None. This simplifies downstream consumers
319
+ # who only care about actionable configuration. Callers cannot distinguish
320
+ # "missing section" from "empty section" - both result in None.
321
+ logger.debug(
322
+ "No operation_bindings content in %s (empty section)",
323
+ contract_path,
324
+ )
325
+ operation_bindings = None
326
+ except ProtocolConfigurationError as e:
327
+ # Check if this is "missing section" or "file not found" which is OK
328
+ if "CONTRACT_NOT_FOUND" in str(e) or "operation_bindings" in str(e).lower():
329
+ logger.debug(
330
+ "No operation_bindings section in %s (this is OK)",
331
+ contract_path,
332
+ )
333
+ else:
334
+ error_msg = f"operation_bindings load failed: {e}"
335
+ logger.warning(
336
+ "Failed to load operation_bindings from %s: %s",
337
+ contract_path,
338
+ e,
339
+ )
340
+ errors.append(error_msg)
341
+ except Exception as e:
342
+ error_msg = f"operation_bindings load failed: {type(e).__name__}: {e}"
343
+ logger.warning(
344
+ "Unexpected error loading operation_bindings from %s: %s",
345
+ contract_path,
346
+ e,
347
+ )
348
+ errors.append(error_msg)
349
+
350
+ # Build result
351
+ if errors:
352
+ combined_error = "; ".join(errors)
353
+ return ModelContractLoadResult.failed(
354
+ contract_path=contract_path,
355
+ error=combined_error,
356
+ correlation_id=correlation_id,
357
+ )
358
+
359
+ return ModelContractLoadResult.succeeded(
360
+ contract_path=contract_path,
361
+ handler_routing=handler_routing,
362
+ operation_bindings=operation_bindings,
363
+ correlation_id=correlation_id,
364
+ )
365
+
366
+ def load_single_contract(
367
+ self,
368
+ contract_path: Path,
369
+ correlation_id: UUID | None = None,
370
+ ) -> ModelContractLoadResult:
371
+ """Load a single contract.yaml file (public API).
372
+
373
+ Convenience method for loading a single contract without scanning.
374
+ Useful for testing or targeted loading.
375
+
376
+ Args:
377
+ contract_path: Path to the contract.yaml file.
378
+ correlation_id: Optional correlation ID for tracing.
379
+
380
+ Returns:
381
+ ModelContractLoadResult with loaded subcontracts or error.
382
+
383
+ Raises:
384
+ ProtocolConfigurationError: If contract_path does not exist.
385
+ """
386
+ correlation_id = correlation_id or uuid4()
387
+
388
+ if not contract_path.exists():
389
+ ctx = ModelInfraErrorContext.with_correlation(
390
+ correlation_id=correlation_id,
391
+ transport_type=EnumInfraTransportType.FILESYSTEM,
392
+ operation="load_single_contract",
393
+ target_name=str(contract_path),
394
+ )
395
+ raise ProtocolConfigurationError(
396
+ f"Contract file not found: {contract_path}",
397
+ context=ctx,
398
+ )
399
+
400
+ return self._load_single_contract(contract_path, correlation_id)
401
+
402
+
403
+ __all__ = [
404
+ "CONTRACT_YAML_FILENAME",
405
+ "RuntimeContractConfigLoader",
406
+ ]