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
@@ -46,20 +46,25 @@ import asyncio
46
46
  import importlib
47
47
  import json
48
48
  import logging
49
+ import os
49
50
  from collections.abc import Awaitable, Callable
50
51
  from pathlib import Path
51
- from typing import TYPE_CHECKING
52
+ from typing import TYPE_CHECKING, cast
52
53
  from uuid import UUID, uuid4
53
54
 
54
55
  from pydantic import BaseModel
55
56
 
56
57
  from omnibase_infra.enums import (
58
+ EnumConsumerGroupPurpose,
57
59
  EnumHandlerSourceMode,
58
60
  EnumHandlerTypeCategory,
59
61
  EnumInfraTransportType,
60
62
  )
61
63
  from omnibase_infra.errors import (
62
64
  EnvelopeValidationError,
65
+ InfraConsulError,
66
+ InfraTimeoutError,
67
+ InfraUnavailableError,
63
68
  ModelInfraErrorContext,
64
69
  ProtocolConfigurationError,
65
70
  RuntimeHostError,
@@ -67,14 +72,22 @@ from omnibase_infra.errors import (
67
72
  )
68
73
  from omnibase_infra.event_bus.event_bus_inmemory import EventBusInmemory
69
74
  from omnibase_infra.event_bus.event_bus_kafka import EventBusKafka
75
+ from omnibase_infra.models import ModelNodeIdentity
70
76
  from omnibase_infra.runtime.envelope_validator import (
71
77
  normalize_correlation_id,
72
78
  validate_envelope,
73
79
  )
74
80
  from omnibase_infra.runtime.handler_registry import RegistryProtocolBinding
75
- from omnibase_infra.runtime.models import ModelDuplicateResponse
81
+ from omnibase_infra.runtime.models import (
82
+ ModelDuplicateResponse,
83
+ ModelRuntimeContractConfig,
84
+ )
76
85
  from omnibase_infra.runtime.protocol_lifecycle_executor import ProtocolLifecycleExecutor
86
+ from omnibase_infra.runtime.runtime_contract_config_loader import (
87
+ RuntimeContractConfigLoader,
88
+ )
77
89
  from omnibase_infra.runtime.util_wiring import wire_default_handlers
90
+ from omnibase_infra.utils.util_consumer_group import compute_consumer_group_id
78
91
  from omnibase_infra.utils.util_env_parsing import parse_env_float
79
92
 
80
93
  if TYPE_CHECKING:
@@ -90,8 +103,14 @@ if TYPE_CHECKING:
90
103
  from omnibase_infra.runtime.contract_handler_discovery import (
91
104
  ContractHandlerDiscovery,
92
105
  )
106
+ from omnibase_infra.runtime.service_message_dispatch_engine import (
107
+ MessageDispatchEngine,
108
+ )
93
109
 
94
110
  # Imports for PluginLoaderContractSource adapter class
111
+ from omnibase_core.protocols.event_bus.protocol_event_bus_subscriber import (
112
+ ProtocolEventBusSubscriber,
113
+ )
95
114
  from omnibase_infra.models.errors import ModelHandlerValidationError
96
115
  from omnibase_infra.models.handlers import (
97
116
  LiteralHandlerKind,
@@ -99,11 +118,20 @@ from omnibase_infra.models.handlers import (
99
118
  ModelHandlerDescriptor,
100
119
  )
101
120
  from omnibase_infra.models.types import JsonDict
121
+ from omnibase_infra.runtime.event_bus_subcontract_wiring import (
122
+ EventBusSubcontractWiring,
123
+ load_event_bus_subcontract,
124
+ )
102
125
  from omnibase_infra.runtime.handler_identity import (
103
126
  HANDLER_IDENTITY_PREFIX,
104
127
  handler_identity,
105
128
  )
106
129
  from omnibase_infra.runtime.handler_plugin_loader import HandlerPluginLoader
130
+ from omnibase_infra.runtime.kafka_contract_source import (
131
+ TOPIC_SUFFIX_CONTRACT_DEREGISTERED,
132
+ TOPIC_SUFFIX_CONTRACT_REGISTERED,
133
+ KafkaContractSource,
134
+ )
107
135
  from omnibase_infra.runtime.protocol_contract_source import ProtocolContractSource
108
136
 
109
137
  # Expose wire_default_handlers as wire_handlers for test patching compatibility
@@ -159,6 +187,51 @@ DEFAULT_DRAIN_TIMEOUT_SECONDS: float = parse_env_float(
159
187
  )
160
188
 
161
189
 
190
+ def _parse_contract_event_payload(
191
+ msg: ModelEventMessage,
192
+ ) -> tuple[dict[str, object], UUID] | None:
193
+ """Parse contract event message payload and extract correlation ID.
194
+
195
+ This helper extracts common JSON parsing and correlation ID extraction logic
196
+ used by contract registration and deregistration handlers.
197
+
198
+ Args:
199
+ msg: The event message to parse.
200
+
201
+ Returns:
202
+ A tuple of (payload_dict, correlation_id) if message has a value,
203
+ None if message value is empty.
204
+
205
+ Raises:
206
+ json.JSONDecodeError: If the message value is not valid JSON.
207
+ UnicodeDecodeError: If the message value cannot be decoded as UTF-8.
208
+
209
+ Note:
210
+ This function is intentionally a module-level utility rather than a
211
+ class method because it performs pure data transformation without
212
+ requiring any class state.
213
+
214
+ .. versionadded:: 0.8.0
215
+ Created for OMN-1654 to reduce duplication in contract event handlers.
216
+ """
217
+ if not msg.value:
218
+ return None
219
+
220
+ payload: dict[str, object] = json.loads(msg.value.decode("utf-8"))
221
+
222
+ # Extract correlation ID from headers if available, or generate new
223
+ correlation_id: UUID
224
+ if msg.headers and msg.headers.correlation_id:
225
+ try:
226
+ correlation_id = UUID(str(msg.headers.correlation_id))
227
+ except (ValueError, TypeError):
228
+ correlation_id = uuid4()
229
+ else:
230
+ correlation_id = uuid4()
231
+
232
+ return (payload, correlation_id)
233
+
234
+
162
235
  class PluginLoaderContractSource(ProtocolContractSource):
163
236
  """Adapter that uses HandlerPluginLoader for contract discovery.
164
237
 
@@ -526,6 +599,9 @@ class RuntimeHostProcess:
526
599
  # Handler discovery service (lazy-created if contract_paths provided)
527
600
  self._handler_discovery: ContractHandlerDiscovery | None = None
528
601
 
602
+ # Kafka contract source (created if KAFKA_EVENTS mode, wired separately)
603
+ self._kafka_contract_source: KafkaContractSource | None = None
604
+
529
605
  # Create or use provided event bus
530
606
  self._event_bus: EventBusInmemory | EventBusKafka = (
531
607
  event_bus or EventBusInmemory()
@@ -537,16 +613,40 @@ class RuntimeHostProcess:
537
613
  # Topic configuration (config overrides constructor args)
538
614
  self._input_topic: str = str(config.get("input_topic", input_topic))
539
615
  self._output_topic: str = str(config.get("output_topic", output_topic))
540
- # Note: ModelRuntimeConfig uses field name "consumer_group" with alias "group_id".
541
- # When config.model_dump() is called, it outputs "consumer_group" by default.
542
- # We check both keys to support either field name or alias.
543
- # Empty strings and whitespace-only strings fall through to the next option.
544
- consumer_group = config.get("consumer_group")
545
- group_id = config.get("group_id")
546
- self._group_id: str = str(
547
- (consumer_group if consumer_group and str(consumer_group).strip() else None)
548
- or (group_id if group_id and str(group_id).strip() else None)
549
- or DEFAULT_GROUP_ID
616
+
617
+ # Node identity configuration (required for consumer group derivation)
618
+ # Extract components from config - fail-fast if required fields are missing
619
+ _env = config.get("env")
620
+ env: str = str(_env).strip() if _env else "local"
621
+
622
+ _service_name = config.get("service_name")
623
+ if not _service_name or not str(_service_name).strip():
624
+ raise ValueError(
625
+ "RuntimeHostProcess requires 'service_name' in config. "
626
+ "This is the service name from your node's contract (e.g., 'omniintelligence'). "
627
+ "Cannot infer service_name - please provide it explicitly."
628
+ )
629
+ service_name: str = str(_service_name).strip()
630
+
631
+ _node_name = config.get("node_name")
632
+ if not _node_name or not str(_node_name).strip():
633
+ raise ValueError(
634
+ "RuntimeHostProcess requires 'node_name' in config. "
635
+ "This is the node name from your contract (e.g., 'claude_hook_event_effect'). "
636
+ "Cannot infer node_name - please provide it explicitly."
637
+ )
638
+ node_name: str = str(_node_name).strip()
639
+
640
+ _version = config.get("version")
641
+ version: str = (
642
+ str(_version).strip() if _version and str(_version).strip() else "v1"
643
+ )
644
+
645
+ self._node_identity: ModelNodeIdentity = ModelNodeIdentity(
646
+ env=env,
647
+ service=service_name,
648
+ node_name=node_name,
649
+ version=version,
550
650
  )
551
651
 
552
652
  # Health check configuration (from lifecycle subcontract pattern)
@@ -678,12 +778,32 @@ class RuntimeHostProcess:
678
778
  self._idempotency_store: ProtocolIdempotencyStore | None = None
679
779
  self._idempotency_config: ModelIdempotencyGuardConfig | None = None
680
780
 
781
+ # Event bus subcontract wiring for contract-driven subscriptions (OMN-1621)
782
+ # Bridges contract-declared topics to Kafka subscriptions.
783
+ # None until wired during start() when dispatch_engine is available.
784
+ self._event_bus_wiring: EventBusSubcontractWiring | None = None
785
+
786
+ # Message dispatch engine for routing received messages (OMN-1621)
787
+ # Used by event_bus_wiring to dispatch messages to handlers.
788
+ # None = not configured, wiring will be skipped
789
+ self._dispatch_engine: MessageDispatchEngine | None = None
790
+
791
+ # Baseline subscriptions for platform-reserved topics (OMN-1654)
792
+ # Stores unsubscribe callbacks for contract registration/deregistration topics.
793
+ # Wired when KAFKA_EVENTS mode is active with a KafkaContractSource.
794
+ self._baseline_subscriptions: list[Callable[[], Awaitable[None]]] = []
795
+
796
+ # Contract configuration loaded at startup (OMN-1519)
797
+ # Contains consolidated handler_routing and operation_bindings from all contracts.
798
+ # None until loaded during start() via _load_contract_configs()
799
+ self._contract_config: ModelRuntimeContractConfig | None = None
800
+
681
801
  logger.debug(
682
802
  "RuntimeHostProcess initialized",
683
803
  extra={
684
804
  "input_topic": self._input_topic,
685
805
  "output_topic": self._output_topic,
686
- "group_id": self._group_id,
806
+ "group_id": self.group_id,
687
807
  "health_check_timeout_seconds": self._health_check_timeout_seconds,
688
808
  "drain_timeout_seconds": self._drain_timeout_seconds,
689
809
  "has_container": self._container is not None,
@@ -702,6 +822,31 @@ class RuntimeHostProcess:
702
822
  """
703
823
  return self._container
704
824
 
825
+ @property
826
+ def contract_config(self) -> ModelRuntimeContractConfig | None:
827
+ """Return the loaded contract configuration.
828
+
829
+ Contains consolidated handler_routing and operation_bindings from all
830
+ contracts discovered during startup. Returns None if contracts have
831
+ not been loaded yet (before start() is called).
832
+
833
+ The contract config provides access to:
834
+ - handler_routing_configs: All loaded handler routing configurations
835
+ - operation_bindings_configs: All loaded operation bindings
836
+ - success_rate: Ratio of successfully loaded contracts
837
+ - error_messages: Any errors encountered during loading
838
+
839
+ Returns:
840
+ ModelRuntimeContractConfig if loaded, None if not yet loaded.
841
+
842
+ Example:
843
+ >>> process = RuntimeHostProcess(...)
844
+ >>> await process.start()
845
+ >>> if process.contract_config:
846
+ ... print(f"Loaded {process.contract_config.total_contracts_loaded} contracts")
847
+ """
848
+ return self._contract_config
849
+
705
850
  @property
706
851
  def event_bus(self) -> EventBusInmemory | EventBusKafka:
707
852
  """Return the owned event bus instance.
@@ -742,10 +887,28 @@ class RuntimeHostProcess:
742
887
  def group_id(self) -> str:
743
888
  """Return the consumer group identifier.
744
889
 
890
+ Computes the consumer group ID from the node identity using the canonical
891
+ format: ``{env}.{service}.{node_name}.{purpose}.{version}``
892
+
893
+ Returns:
894
+ The computed consumer group ID for this process.
895
+ """
896
+ return compute_consumer_group_id(
897
+ self._node_identity, EnumConsumerGroupPurpose.CONSUME
898
+ )
899
+
900
+ @property
901
+ def node_identity(self) -> ModelNodeIdentity:
902
+ """Return the node identity used for consumer group derivation.
903
+
904
+ The node identity contains the environment, service name, node name,
905
+ and version that uniquely identify this runtime host process within
906
+ the ONEX infrastructure.
907
+
745
908
  Returns:
746
- The consumer group ID for this process.
909
+ The immutable node identity for this process.
747
910
  """
748
- return self._group_id
911
+ return self._node_identity
749
912
 
750
913
  @property
751
914
  def is_draining(self) -> bool:
@@ -877,7 +1040,7 @@ class RuntimeHostProcess:
877
1040
  extra={
878
1041
  "input_topic": self._input_topic,
879
1042
  "output_topic": self._output_topic,
880
- "group_id": self._group_id,
1043
+ "group_id": self.group_id,
881
1044
  "has_contract_paths": len(self._contract_paths) > 0,
882
1045
  },
883
1046
  )
@@ -980,14 +1143,32 @@ class RuntimeHostProcess:
980
1143
  registry_protocol_count=registry_protocol_count,
981
1144
  )
982
1145
 
1146
+ # Step 4.15: Load contract configurations (OMN-1519)
1147
+ # Loads handler_routing and operation_bindings from all discovered contracts.
1148
+ # Uses the same contract_paths configured for handler discovery.
1149
+ # The loaded config is accessible via self.contract_config property.
1150
+ startup_correlation_id = uuid4()
1151
+ await self._load_contract_configs(correlation_id=startup_correlation_id)
1152
+
1153
+ # Step 4.2: Wire event bus subscriptions from contracts (OMN-1621)
1154
+ # This bridges contract-declared topics to Kafka subscriptions.
1155
+ # Requires dispatch_engine to be available for message routing.
1156
+ await self._wire_event_bus_subscriptions()
1157
+
1158
+ # Step 4.3: Wire baseline subscriptions for contract discovery (OMN-1654)
1159
+ # When KAFKA_EVENTS mode is active, subscribe to platform-reserved
1160
+ # contract topics to receive registration/deregistration events.
1161
+ await self._wire_baseline_subscriptions()
1162
+
983
1163
  # Step 4.5: Initialize idempotency store if configured (OMN-945)
984
1164
  await self._initialize_idempotency_store()
985
1165
 
986
1166
  # Step 5: Subscribe to input topic
987
1167
  self._subscription = await self._event_bus.subscribe(
988
1168
  topic=self._input_topic,
989
- group_id=self._group_id,
1169
+ node_identity=self._node_identity,
990
1170
  on_message=self._on_message,
1171
+ purpose=EnumConsumerGroupPurpose.CONSUME,
991
1172
  )
992
1173
 
993
1174
  self._is_running = True
@@ -997,7 +1178,7 @@ class RuntimeHostProcess:
997
1178
  extra={
998
1179
  "input_topic": self._input_topic,
999
1180
  "output_topic": self._output_topic,
1000
- "group_id": self._group_id,
1181
+ "group_id": self.group_id,
1001
1182
  "registered_handlers": list(self._handlers.keys()),
1002
1183
  },
1003
1184
  )
@@ -1148,6 +1329,26 @@ class RuntimeHostProcess:
1148
1329
  # Step 2.5: Cleanup idempotency store if initialized (OMN-945)
1149
1330
  await self._cleanup_idempotency_store()
1150
1331
 
1332
+ # Step 2.6: Cleanup event bus subcontract wiring (OMN-1621)
1333
+ if self._event_bus_wiring:
1334
+ await self._event_bus_wiring.cleanup()
1335
+
1336
+ # Step 2.7: Cleanup baseline subscriptions for contract discovery (OMN-1654)
1337
+ if self._baseline_subscriptions:
1338
+ for unsubscribe in self._baseline_subscriptions:
1339
+ try:
1340
+ await unsubscribe()
1341
+ except Exception as e:
1342
+ logger.warning(
1343
+ "Failed to unsubscribe baseline subscription",
1344
+ extra={"error": str(e)},
1345
+ )
1346
+ self._baseline_subscriptions.clear()
1347
+ logger.debug("Baseline contract subscriptions cleaned up")
1348
+
1349
+ # Step 2.8: Nullify KafkaContractSource reference for proper cleanup (OMN-1654)
1350
+ self._kafka_contract_source = None
1351
+
1151
1352
  # Step 3: Close event bus
1152
1353
  await self._event_bus.close()
1153
1354
 
@@ -1295,12 +1496,34 @@ class RuntimeHostProcess:
1295
1496
  # Create bootstrap source
1296
1497
  bootstrap_source = HandlerBootstrapSource()
1297
1498
 
1499
+ # Check for KAFKA_EVENTS mode first
1500
+ if source_config.effective_mode == EnumHandlerSourceMode.KAFKA_EVENTS:
1501
+ # Create Kafka-based contract source (cache-only beta)
1502
+ # Note: Kafka subscriptions are wired separately in _wire_baseline_subscriptions()
1503
+ environment = self._get_environment_from_config()
1504
+ kafka_source = KafkaContractSource(
1505
+ environment=environment,
1506
+ graceful_mode=True,
1507
+ )
1508
+ contract_source: ProtocolContractSource = kafka_source
1509
+
1510
+ # Store reference for subscription wiring
1511
+ self._kafka_contract_source = kafka_source
1512
+
1513
+ logger.info(
1514
+ "Using KafkaContractSource for contract discovery",
1515
+ extra={
1516
+ "environment": environment,
1517
+ "mode": "KAFKA_EVENTS",
1518
+ "correlation_id": str(kafka_source.correlation_id),
1519
+ },
1520
+ )
1298
1521
  # Contract source needs paths - use configured paths or default
1299
1522
  # If no contract_paths provided, reuse bootstrap_source as placeholder
1300
- if self._contract_paths:
1523
+ elif self._contract_paths:
1301
1524
  # Use PluginLoaderContractSource which uses the simpler contract schema
1302
1525
  # compatible with test contracts (handler_name, handler_class, handler_type)
1303
- contract_source: ProtocolContractSource = PluginLoaderContractSource(
1526
+ contract_source = PluginLoaderContractSource(
1304
1527
  contract_paths=self._contract_paths,
1305
1528
  )
1306
1529
  else:
@@ -1667,6 +1890,77 @@ class RuntimeHostProcess:
1667
1890
  },
1668
1891
  )
1669
1892
 
1893
+ async def _load_contract_configs(self, correlation_id: UUID) -> None:
1894
+ """Load contract configurations from all discovered contracts.
1895
+
1896
+ Uses RuntimeContractConfigLoader to scan for contract.yaml files and
1897
+ load handler_routing and operation_bindings subcontracts into a
1898
+ consolidated configuration.
1899
+
1900
+ This method is called during start() after handler discovery but before
1901
+ event bus subscriptions are wired. The loaded config is stored in
1902
+ self._contract_config and accessible via the contract_config property.
1903
+
1904
+ Error Handling:
1905
+ Individual contract load failures are logged but do not stop the
1906
+ overall loading process. This enables graceful degradation where
1907
+ some contracts can be loaded even if others fail. Errors are
1908
+ collected in the ModelRuntimeContractConfig for introspection.
1909
+
1910
+ Args:
1911
+ correlation_id: Correlation ID for tracing this load operation.
1912
+
1913
+ Part of OMN-1519: Runtime contract config loader integration.
1914
+ """
1915
+ # Skip if no contract paths configured
1916
+ if not self._contract_paths:
1917
+ logger.debug(
1918
+ "No contract paths configured, skipping contract config loading",
1919
+ extra={"correlation_id": str(correlation_id)},
1920
+ )
1921
+ return
1922
+
1923
+ # Create loader - no namespace restrictions by default
1924
+ # (namespace allowlisting can be added via constructor parameter if needed)
1925
+ loader = RuntimeContractConfigLoader()
1926
+
1927
+ # Load all contracts from configured paths
1928
+ self._contract_config = loader.load_all_contracts(
1929
+ search_paths=self._contract_paths,
1930
+ correlation_id=correlation_id,
1931
+ )
1932
+
1933
+ # Log summary at INFO level
1934
+ if self._contract_config.total_errors > 0:
1935
+ logger.warning(
1936
+ "Contract config loading completed with errors",
1937
+ extra={
1938
+ "total_contracts_found": self._contract_config.total_contracts_found,
1939
+ "total_contracts_loaded": self._contract_config.total_contracts_loaded,
1940
+ "total_errors": self._contract_config.total_errors,
1941
+ "success_rate": f"{self._contract_config.success_rate:.1%}",
1942
+ "correlation_id": str(correlation_id),
1943
+ "error_paths": [
1944
+ str(p) for p in self._contract_config.error_messages
1945
+ ],
1946
+ },
1947
+ )
1948
+ else:
1949
+ logger.info(
1950
+ "Contract config loading completed successfully",
1951
+ extra={
1952
+ "total_contracts_found": self._contract_config.total_contracts_found,
1953
+ "total_contracts_loaded": self._contract_config.total_contracts_loaded,
1954
+ "handler_routing_count": len(
1955
+ self._contract_config.handler_routing_configs
1956
+ ),
1957
+ "operation_bindings_count": len(
1958
+ self._contract_config.operation_bindings_configs
1959
+ ),
1960
+ "correlation_id": str(correlation_id),
1961
+ },
1962
+ )
1963
+
1670
1964
  async def _get_handler_registry(self) -> RegistryProtocolBinding:
1671
1965
  """Get handler registry (pre-resolved, container, or singleton).
1672
1966
 
@@ -2213,6 +2507,171 @@ class RuntimeHostProcess:
2213
2507
  """
2214
2508
  return self._handlers.get(handler_type)
2215
2509
 
2510
+ async def get_subscribers_for_topic(self, topic: str) -> list[UUID]:
2511
+ """Query Consul for node IDs that subscribe to a topic.
2512
+
2513
+ This method provides dynamic topic-to-subscriber lookup via Consul KV store.
2514
+ Topics are stored at `onex/topics/{topic}/subscribers` and contain a JSON
2515
+ array of node UUID strings.
2516
+
2517
+ Args:
2518
+ topic: Environment-qualified topic string
2519
+ (e.g., "dev.onex.evt.intent-classified.v1")
2520
+
2521
+ Returns:
2522
+ List of node UUIDs that subscribe to this topic.
2523
+ Empty list if no subscribers registered or Consul unavailable.
2524
+
2525
+ Note:
2526
+ Returns node IDs, not handler names. Node ID is the stable registry key.
2527
+ Handler selection is a separate concern that can change independently.
2528
+
2529
+ Example:
2530
+ >>> runtime = RuntimeHostProcess()
2531
+ >>> await runtime.start()
2532
+ >>> subscribers = await runtime.get_subscribers_for_topic(
2533
+ ... "dev.onex.evt.intent-classified.v1"
2534
+ ... )
2535
+ >>> print(subscribers) # [UUID('abc123...'), UUID('def456...')]
2536
+
2537
+ Related:
2538
+ - OMN-1613: Add event bus topic storage to registry for dynamic topic discovery
2539
+ - MixinConsulTopicIndex: Consul mixin that manages topic index storage
2540
+ """
2541
+ consul_handler = self.get_handler("consul")
2542
+ if consul_handler is None:
2543
+ logger.debug(
2544
+ "Consul handler not available for topic subscriber lookup",
2545
+ extra={"topic": topic},
2546
+ )
2547
+ return []
2548
+
2549
+ try:
2550
+ correlation_id = uuid4()
2551
+ envelope: dict[str, object] = {
2552
+ "operation": "consul.kv_get",
2553
+ "payload": {"key": f"onex/topics/{topic}/subscribers"},
2554
+ "correlation_id": str(correlation_id),
2555
+ }
2556
+
2557
+ # Execute the Consul KV get operation
2558
+ # NOTE: MVP adapters use legacy execute(envelope: dict) signature.
2559
+ result = await consul_handler.execute(envelope) # type: ignore[call-arg]
2560
+
2561
+ # Navigate to the value in the response structure:
2562
+ # ModelHandlerOutput -> result (ModelConsulHandlerResponse)
2563
+ # -> payload (ModelConsulHandlerPayload) -> data (ConsulPayload)
2564
+ if result is None:
2565
+ return []
2566
+
2567
+ # Check if result has the expected structure
2568
+ if not hasattr(result, "result") or result.result is None:
2569
+ return []
2570
+
2571
+ response = result.result
2572
+ if not hasattr(response, "payload") or response.payload is None:
2573
+ return []
2574
+
2575
+ payload_data = response.payload.data
2576
+ if payload_data is None:
2577
+ return []
2578
+
2579
+ # Check for "not found" response - key doesn't exist
2580
+ if hasattr(payload_data, "found") and payload_data.found is False:
2581
+ return []
2582
+
2583
+ # Get the value field from the payload
2584
+ value = getattr(payload_data, "value", None)
2585
+ if not value:
2586
+ return []
2587
+
2588
+ # Parse JSON array of node ID strings
2589
+ node_ids_raw = json.loads(value)
2590
+ if not isinstance(node_ids_raw, list):
2591
+ logger.warning(
2592
+ "Topic subscriber value is not a list",
2593
+ extra={
2594
+ "topic": topic,
2595
+ "correlation_id": str(correlation_id),
2596
+ "value_type": type(node_ids_raw).__name__,
2597
+ },
2598
+ )
2599
+ return []
2600
+
2601
+ # Convert string UUIDs to UUID objects (skip invalid entries)
2602
+ subscribers: list[UUID] = []
2603
+ invalid_ids: list[str] = []
2604
+ for nid in node_ids_raw:
2605
+ if not isinstance(nid, str):
2606
+ continue
2607
+ try:
2608
+ subscribers.append(UUID(nid))
2609
+ except ValueError:
2610
+ invalid_ids.append(nid)
2611
+
2612
+ if invalid_ids:
2613
+ logger.warning(
2614
+ "Invalid UUIDs in topic subscriber list",
2615
+ extra={
2616
+ "topic": topic,
2617
+ "correlation_id": str(correlation_id),
2618
+ "invalid_count": len(invalid_ids),
2619
+ },
2620
+ )
2621
+ return subscribers
2622
+
2623
+ except json.JSONDecodeError as e:
2624
+ logger.warning(
2625
+ "Failed to parse topic subscriber JSON",
2626
+ extra={
2627
+ "topic": topic,
2628
+ "error": str(e),
2629
+ },
2630
+ )
2631
+ return []
2632
+ except InfraConsulError as e:
2633
+ logger.warning(
2634
+ "Consul error querying topic subscribers",
2635
+ extra={
2636
+ "topic": topic,
2637
+ "error": str(e),
2638
+ "error_type": "InfraConsulError",
2639
+ "consul_key": getattr(e, "consul_key", None),
2640
+ },
2641
+ )
2642
+ return []
2643
+ except InfraTimeoutError as e:
2644
+ logger.warning(
2645
+ "Timeout querying topic subscribers",
2646
+ extra={
2647
+ "topic": topic,
2648
+ "error": str(e),
2649
+ "error_type": "InfraTimeoutError",
2650
+ },
2651
+ )
2652
+ return []
2653
+ except InfraUnavailableError as e:
2654
+ logger.warning(
2655
+ "Service unavailable for topic subscriber query",
2656
+ extra={
2657
+ "topic": topic,
2658
+ "error": str(e),
2659
+ "error_type": "InfraUnavailableError",
2660
+ },
2661
+ )
2662
+ return []
2663
+ except Exception as e:
2664
+ # Graceful degradation - Consul unavailable is not fatal
2665
+ logger.warning(
2666
+ "Failed to query topic subscribers from Consul",
2667
+ extra={
2668
+ "topic": topic,
2669
+ "error": str(e),
2670
+ "error_type": type(e).__name__,
2671
+ },
2672
+ )
2673
+ return []
2674
+
2216
2675
  # =========================================================================
2217
2676
  # Architecture Validation Methods (OMN-1138)
2218
2677
  # =========================================================================
@@ -2383,6 +2842,297 @@ class RuntimeHostProcess:
2383
2842
  self._container = ModelONEXContainer()
2384
2843
  return self._container
2385
2844
 
2845
+ def _get_environment_from_config(self) -> str:
2846
+ """Extract environment setting from config with consistent fallback.
2847
+
2848
+ Handles both dict-based config and object-based config (e.g., Pydantic models)
2849
+ with a unified access pattern.
2850
+
2851
+ Resolution order:
2852
+ 1. config["event_bus"]["environment"] (if config is dict-like)
2853
+ 2. config.event_bus.environment (if config is object-like)
2854
+ 3. ONEX_ENVIRONMENT environment variable
2855
+ 4. "dev" (hardcoded default)
2856
+
2857
+ Returns:
2858
+ Environment string (e.g., "dev", "staging", "prod").
2859
+ """
2860
+ default_env = os.getenv("ONEX_ENVIRONMENT", "dev")
2861
+ config = self._config or {}
2862
+
2863
+ event_bus_config = config.get("event_bus", {})
2864
+ if isinstance(event_bus_config, dict):
2865
+ return str(event_bus_config.get("environment", default_env))
2866
+
2867
+ # Object-based config (e.g., ModelEventBusConfig)
2868
+ return str(getattr(event_bus_config, "environment", default_env))
2869
+
2870
+ # =========================================================================
2871
+ # Event Bus Subcontract Wiring Methods (OMN-1621)
2872
+ # =========================================================================
2873
+
2874
+ async def _wire_event_bus_subscriptions(self) -> None:
2875
+ """Wire Kafka subscriptions from handler contract event_bus sections.
2876
+
2877
+ This method bridges contract-declared topics to actual Kafka subscriptions
2878
+ using the EventBusSubcontractWiring class. It reads the event_bus subcontract
2879
+ from each handler's contract YAML and creates subscriptions for declared
2880
+ subscribe_topics.
2881
+
2882
+ Preconditions:
2883
+ - self._event_bus must be available and started
2884
+ - self._dispatch_engine must be set (otherwise wiring is skipped)
2885
+ - self._handler_descriptors must be populated
2886
+
2887
+ The wiring creates subscriptions that route messages to the dispatch engine,
2888
+ which then routes to appropriate handlers based on topic/category matching.
2889
+
2890
+ Per ARCH-002: "Runtime owns all Kafka plumbing" - nodes and handlers declare
2891
+ their topic requirements in contracts but never directly interact with Kafka.
2892
+
2893
+ Note:
2894
+ If dispatch_engine is not configured, this method logs a debug message
2895
+ and returns without creating any subscriptions. This allows the runtime
2896
+ to operate in legacy mode without contract-driven subscriptions.
2897
+
2898
+ .. versionadded:: 0.2.5
2899
+ Part of OMN-1621 contract-driven event bus wiring.
2900
+ """
2901
+ # Guard: require both event_bus and dispatch_engine
2902
+ if not self._event_bus:
2903
+ logger.debug("Event bus not available, skipping subcontract wiring")
2904
+ return
2905
+
2906
+ if not self._dispatch_engine:
2907
+ logger.debug(
2908
+ "Dispatch engine not configured, skipping event bus subcontract wiring"
2909
+ )
2910
+ return
2911
+
2912
+ if not self._handler_descriptors:
2913
+ logger.debug(
2914
+ "No handler descriptors available, skipping subcontract wiring"
2915
+ )
2916
+ return
2917
+
2918
+ environment = self._get_environment_from_config()
2919
+
2920
+ # Create wiring instance
2921
+ # Cast to protocol type - both EventBusKafka and EventBusInmemory implement
2922
+ # the ProtocolEventBusSubscriber interface (subscribe method)
2923
+ self._event_bus_wiring = EventBusSubcontractWiring(
2924
+ event_bus=cast("ProtocolEventBusSubscriber", self._event_bus),
2925
+ dispatch_engine=self._dispatch_engine,
2926
+ environment=environment,
2927
+ )
2928
+
2929
+ # Wire subscriptions for each handler with a contract
2930
+ wired_count = 0
2931
+ for handler_type, descriptor in self._handler_descriptors.items():
2932
+ contract_path_str = descriptor.contract_path
2933
+ if not contract_path_str:
2934
+ continue
2935
+
2936
+ contract_path = Path(contract_path_str)
2937
+
2938
+ # Load event_bus subcontract from contract YAML
2939
+ subcontract = load_event_bus_subcontract(contract_path, logger)
2940
+ if subcontract and subcontract.subscribe_topics:
2941
+ await self._event_bus_wiring.wire_subscriptions(
2942
+ subcontract=subcontract,
2943
+ node_name=descriptor.name or handler_type,
2944
+ )
2945
+ wired_count += 1
2946
+ logger.info(
2947
+ "Wired subscription(s) for handler '%s': topics=%s",
2948
+ descriptor.name or handler_type,
2949
+ subcontract.subscribe_topics,
2950
+ )
2951
+
2952
+ if wired_count > 0:
2953
+ logger.info(
2954
+ "Event bus subcontract wiring complete",
2955
+ extra={
2956
+ "wired_handler_count": wired_count,
2957
+ "total_handler_count": len(self._handler_descriptors),
2958
+ "environment": environment,
2959
+ },
2960
+ )
2961
+ else:
2962
+ logger.debug(
2963
+ "No handlers with event_bus subscriptions found",
2964
+ extra={"handler_count": len(self._handler_descriptors)},
2965
+ )
2966
+
2967
+ async def _wire_baseline_subscriptions(self) -> None:
2968
+ """Wire platform-baseline topic subscriptions for contract discovery.
2969
+
2970
+ These subscriptions are wired at runtime startup to receive contract
2971
+ registration and deregistration events from Kafka. This enables
2972
+ dynamic contract discovery without polling.
2973
+
2974
+ The subscriptions route events to KafkaContractSource callbacks:
2975
+ - on_contract_registered(): Parses contract YAML and caches descriptor
2976
+ - on_contract_deregistered(): Removes descriptor from cache
2977
+
2978
+ Preconditions:
2979
+ - KAFKA_EVENTS mode must be active (self._kafka_contract_source set)
2980
+ - Event bus must be available and started
2981
+
2982
+ Topic Format:
2983
+ - Registration: {env}.{TOPIC_SUFFIX_CONTRACT_REGISTERED}
2984
+ - Deregistration: {env}.{TOPIC_SUFFIX_CONTRACT_DEREGISTERED}
2985
+
2986
+ Note:
2987
+ Unsubscribe callbacks are stored in self._baseline_subscriptions
2988
+ for cleanup during stop().
2989
+
2990
+ Part of OMN-1654: KafkaContractSource cache discovery.
2991
+
2992
+ .. versionadded:: 0.8.0
2993
+ Created for event-driven contract discovery.
2994
+ """
2995
+ # Guard: only wire if KafkaContractSource is active
2996
+ if self._kafka_contract_source is None:
2997
+ logger.debug(
2998
+ "KafkaContractSource not active, skipping baseline subscriptions"
2999
+ )
3000
+ return
3001
+
3002
+ # Guard: event bus must be available
3003
+ if self._event_bus is None:
3004
+ logger.warning(
3005
+ "Event bus not available, cannot wire baseline contract subscriptions",
3006
+ extra={"mode": "KAFKA_EVENTS"},
3007
+ )
3008
+ return
3009
+
3010
+ source = self._kafka_contract_source
3011
+ environment = source.environment
3012
+
3013
+ # Compose topic names using platform-reserved suffixes
3014
+ registration_topic = f"{environment}.{TOPIC_SUFFIX_CONTRACT_REGISTERED}"
3015
+ deregistration_topic = f"{environment}.{TOPIC_SUFFIX_CONTRACT_DEREGISTERED}"
3016
+
3017
+ # Import ModelEventMessage type for handler signature
3018
+ from omnibase_infra.event_bus.models.model_event_message import (
3019
+ ModelEventMessage,
3020
+ )
3021
+
3022
+ async def handle_registration(msg: ModelEventMessage) -> None:
3023
+ """Handle contract registration event from Kafka."""
3024
+ try:
3025
+ parsed = _parse_contract_event_payload(msg)
3026
+ if parsed is None:
3027
+ return
3028
+
3029
+ payload, correlation_id = parsed
3030
+
3031
+ source.on_contract_registered(
3032
+ node_name=str(payload.get("node_name", "")),
3033
+ contract_yaml=str(payload.get("contract_yaml", "")),
3034
+ correlation_id=correlation_id,
3035
+ )
3036
+
3037
+ logger.debug(
3038
+ "Processed contract registration event",
3039
+ extra={
3040
+ "node_name": payload.get("node_name"),
3041
+ "topic": registration_topic,
3042
+ "correlation_id": str(correlation_id),
3043
+ },
3044
+ )
3045
+
3046
+ except Exception as e:
3047
+ logger.warning(
3048
+ "Failed to process contract registration event",
3049
+ extra={
3050
+ "error": str(e),
3051
+ "error_type": type(e).__name__,
3052
+ "topic": registration_topic,
3053
+ },
3054
+ )
3055
+
3056
+ async def handle_deregistration(msg: ModelEventMessage) -> None:
3057
+ """Handle contract deregistration event from Kafka."""
3058
+ try:
3059
+ parsed = _parse_contract_event_payload(msg)
3060
+ if parsed is None:
3061
+ return
3062
+
3063
+ payload, correlation_id = parsed
3064
+
3065
+ source.on_contract_deregistered(
3066
+ node_name=str(payload.get("node_name", "")),
3067
+ correlation_id=correlation_id,
3068
+ )
3069
+
3070
+ logger.debug(
3071
+ "Processed contract deregistration event",
3072
+ extra={
3073
+ "node_name": payload.get("node_name"),
3074
+ "topic": deregistration_topic,
3075
+ "correlation_id": str(correlation_id),
3076
+ },
3077
+ )
3078
+
3079
+ except Exception as e:
3080
+ logger.warning(
3081
+ "Failed to process contract deregistration event",
3082
+ extra={
3083
+ "error": str(e),
3084
+ "error_type": type(e).__name__,
3085
+ "topic": deregistration_topic,
3086
+ },
3087
+ )
3088
+
3089
+ # Subscribe to topics
3090
+ try:
3091
+ # Create node identity for baseline subscriptions
3092
+ baseline_identity = ModelNodeIdentity(
3093
+ env=environment,
3094
+ service=self._node_identity.service,
3095
+ node_name=f"{self._node_identity.node_name}-contract-discovery",
3096
+ version=self._node_identity.version,
3097
+ )
3098
+
3099
+ # Subscribe to registration topic
3100
+ reg_unsub = await self._event_bus.subscribe(
3101
+ topic=registration_topic,
3102
+ node_identity=baseline_identity,
3103
+ on_message=handle_registration,
3104
+ purpose=EnumConsumerGroupPurpose.CONSUME,
3105
+ )
3106
+ self._baseline_subscriptions.append(reg_unsub)
3107
+
3108
+ # Subscribe to deregistration topic
3109
+ dereg_unsub = await self._event_bus.subscribe(
3110
+ topic=deregistration_topic,
3111
+ node_identity=baseline_identity,
3112
+ on_message=handle_deregistration,
3113
+ purpose=EnumConsumerGroupPurpose.CONSUME,
3114
+ )
3115
+ self._baseline_subscriptions.append(dereg_unsub)
3116
+
3117
+ logger.info(
3118
+ "Wired baseline contract subscriptions",
3119
+ extra={
3120
+ "registration_topic": registration_topic,
3121
+ "deregistration_topic": deregistration_topic,
3122
+ "environment": environment,
3123
+ "subscription_count": len(self._baseline_subscriptions),
3124
+ },
3125
+ )
3126
+
3127
+ except Exception:
3128
+ logger.exception(
3129
+ "Failed to wire baseline subscriptions",
3130
+ extra={
3131
+ "registration_topic": registration_topic,
3132
+ "deregistration_topic": deregistration_topic,
3133
+ },
3134
+ )
3135
+
2386
3136
  # =========================================================================
2387
3137
  # Idempotency Guard Methods (OMN-945)
2388
3138
  # =========================================================================