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
@@ -146,13 +146,24 @@ from pydantic import ValidationError
146
146
  from omnibase_core.enums import EnumCoreErrorCode
147
147
  from omnibase_core.models.errors import ModelOnexError
148
148
  from omnibase_core.models.events.model_event_envelope import ModelEventEnvelope
149
- from omnibase_core.types import PrimitiveValue
149
+ from omnibase_core.types import JsonType, PrimitiveValue
150
150
  from omnibase_infra.enums import (
151
151
  EnumDispatchStatus,
152
152
  EnumInfraTransportType,
153
153
  EnumMessageCategory,
154
154
  )
155
- from omnibase_infra.errors import ModelInfraErrorContext, ProtocolConfigurationError
155
+ from omnibase_infra.errors import (
156
+ BindingResolutionError,
157
+ ModelInfraErrorContext,
158
+ ProtocolConfigurationError,
159
+ )
160
+ from omnibase_infra.models.bindings import (
161
+ ModelBindingResolutionResult,
162
+ ModelOperationBindingsSubcontract,
163
+ )
164
+ from omnibase_infra.models.dispatch.model_debug_trace_snapshot import (
165
+ ModelDebugTraceSnapshot,
166
+ )
156
167
  from omnibase_infra.models.dispatch.model_dispatch_context import ModelDispatchContext
157
168
  from omnibase_infra.models.dispatch.model_dispatch_log_context import (
158
169
  ModelDispatchLogContext,
@@ -165,6 +176,10 @@ from omnibase_infra.models.dispatch.model_dispatch_route import ModelDispatchRou
165
176
  from omnibase_infra.models.dispatch.model_dispatcher_metrics import (
166
177
  ModelDispatcherMetrics,
167
178
  )
179
+ from omnibase_infra.models.dispatch.model_materialized_dispatch import (
180
+ ModelMaterializedDispatch,
181
+ )
182
+ from omnibase_infra.runtime.binding_resolver import OperationBindingResolver
168
183
  from omnibase_infra.runtime.dispatch_context_enforcer import DispatchContextEnforcer
169
184
  from omnibase_infra.utils import sanitize_error_message
170
185
 
@@ -254,11 +269,14 @@ ContextAwareDispatcherFunc = Callable[
254
269
 
255
270
  # Sync-only dispatcher type for use with run_in_executor
256
271
  # Used internally after runtime type narrowing via inspect.iscoroutinefunction
257
- _SyncDispatcherFunc = Callable[[ModelEventEnvelope[object]], DispatcherOutput]
272
+ # All envelopes are materialized to dict format with __bindings namespace
273
+ _SyncDispatcherFunc = Callable[[dict[str, object]], DispatcherOutput]
258
274
 
259
275
  # Sync-only context-aware dispatcher type for use with run_in_executor
276
+ # All envelopes are materialized to dict format with __bindings namespace
260
277
  _SyncContextAwareDispatcherFunc = Callable[
261
- [ModelEventEnvelope[object], ModelDispatchContext], DispatcherOutput
278
+ [dict[str, object], ModelDispatchContext],
279
+ DispatcherOutput,
262
280
  ]
263
281
 
264
282
 
@@ -283,6 +301,10 @@ class DispatchEntryInternal:
283
301
  accepts_context: Cached result of signature inspection indicating
284
302
  whether the dispatcher accepts a context parameter (2+ params).
285
303
  Computed once at registration time for performance.
304
+ operation_bindings: Optional declarative bindings for resolving
305
+ handler parameters from envelope/payload/context. When set,
306
+ bindings are resolved BEFORE handler execution and materialized
307
+ into a new envelope with __bindings namespace.
286
308
  """
287
309
 
288
310
  __slots__ = (
@@ -292,6 +314,7 @@ class DispatchEntryInternal:
292
314
  "dispatcher_id",
293
315
  "message_types",
294
316
  "node_kind",
317
+ "operation_bindings",
295
318
  )
296
319
 
297
320
  def __init__(
@@ -302,6 +325,7 @@ class DispatchEntryInternal:
302
325
  message_types: set[str] | None,
303
326
  node_kind: EnumNodeKind | None = None,
304
327
  accepts_context: bool = False,
328
+ operation_bindings: ModelOperationBindingsSubcontract | None = None,
305
329
  ) -> None:
306
330
  self.dispatcher_id = dispatcher_id
307
331
  self.dispatcher = dispatcher
@@ -309,6 +333,9 @@ class DispatchEntryInternal:
309
333
  self.message_types = message_types # None means "all types"
310
334
  self.node_kind = node_kind # None means no context injection
311
335
  self.accepts_context = accepts_context # Cached: dispatcher has 2+ params
336
+ self.operation_bindings = (
337
+ operation_bindings # Declarative bindings for this dispatcher
338
+ )
312
339
 
313
340
 
314
341
  class MessageDispatchEngine:
@@ -460,6 +487,10 @@ class MessageDispatchEngine:
460
487
  # Delegates time injection rule enforcement to a single source of truth.
461
488
  self._context_enforcer: DispatchContextEnforcer = DispatchContextEnforcer()
462
489
 
490
+ # Binding resolver for declarative operation bindings.
491
+ # Resolves ${source.path} expressions from envelope/payload/context.
492
+ self._binding_resolver: OperationBindingResolver = OperationBindingResolver()
493
+
463
494
  def register_route(self, route: ModelDispatchRoute) -> None:
464
495
  """
465
496
  Register a routing rule.
@@ -542,6 +573,7 @@ class MessageDispatchEngine:
542
573
  category: EnumMessageCategory,
543
574
  message_types: set[str] | None = None,
544
575
  node_kind: None = None,
576
+ operation_bindings: ModelOperationBindingsSubcontract | None = None,
545
577
  ) -> None: ... # Stub: no node_kind -> DispatcherFunc (no context)
546
578
 
547
579
  @overload
@@ -553,6 +585,7 @@ class MessageDispatchEngine:
553
585
  message_types: set[str] | None = None,
554
586
  *,
555
587
  node_kind: EnumNodeKind,
588
+ operation_bindings: ModelOperationBindingsSubcontract | None = None,
556
589
  ) -> None: ... # Stub: with node_kind -> ContextAwareDispatcherFunc (gets context)
557
590
 
558
591
  def register_dispatcher(
@@ -562,6 +595,7 @@ class MessageDispatchEngine:
562
595
  category: EnumMessageCategory,
563
596
  message_types: set[str] | None = None,
564
597
  node_kind: EnumNodeKind | None = None,
598
+ operation_bindings: ModelOperationBindingsSubcontract | None = None,
565
599
  ) -> None:
566
600
  """
567
601
  Register a message dispatcher.
@@ -585,6 +619,11 @@ class MessageDispatchEngine:
585
619
  - REDUCER/COMPUTE: now=None (deterministic execution)
586
620
  - ORCHESTRATOR/EFFECT/RUNTIME_HOST: now=datetime.now(UTC)
587
621
  When None, dispatcher is called without context.
622
+ operation_bindings: Optional declarative bindings for resolving
623
+ handler parameters from envelope/payload/context. When provided,
624
+ bindings are resolved BEFORE handler execution and materialized
625
+ into a new envelope with __bindings namespace. The original
626
+ envelope is NEVER mutated.
588
627
 
589
628
  Raises:
590
629
  ModelOnexError: If engine is frozen (INVALID_STATE)
@@ -688,9 +727,23 @@ class MessageDispatchEngine:
688
727
  message_types=message_types,
689
728
  node_kind=node_kind,
690
729
  accepts_context=accepts_context,
730
+ operation_bindings=operation_bindings,
691
731
  )
692
732
  self._dispatchers[dispatcher_id] = entry
693
733
 
734
+ # Log requirement for operation_bindings users
735
+ # NOTE: When operation_bindings is provided, envelopes dispatched to this
736
+ # handler MUST have an 'operation' attribute/key. The operation field is
737
+ # extracted at dispatch time via _extract_operation() and used to select
738
+ # the appropriate binding configuration. Missing operation fields will
739
+ # result in binding resolution failures at dispatch time.
740
+ if operation_bindings is not None:
741
+ self._logger.debug(
742
+ "Dispatcher '%s' registered with operation_bindings. "
743
+ "Envelopes MUST have an 'operation' attribute/key for binding resolution.",
744
+ dispatcher_id,
745
+ )
746
+
694
747
  # Update category index
695
748
  self._dispatchers_by_category[category].append(dispatcher_id)
696
749
 
@@ -1048,7 +1101,9 @@ class MessageDispatchEngine:
1048
1101
  )
1049
1102
 
1050
1103
  try:
1051
- result = await self._execute_dispatcher(dispatcher_entry, envelope)
1104
+ result = await self._execute_dispatcher(
1105
+ dispatcher_entry, envelope, topic
1106
+ )
1052
1107
  dispatcher_duration_ms = (
1053
1108
  time.perf_counter() - dispatcher_start_time
1054
1109
  ) * 1000
@@ -1433,6 +1488,7 @@ class MessageDispatchEngine:
1433
1488
  self,
1434
1489
  entry: DispatchEntryInternal,
1435
1490
  envelope: ModelEventEnvelope[object],
1491
+ topic: str,
1436
1492
  ) -> DispatcherOutput:
1437
1493
  """
1438
1494
  Execute a dispatcher (sync or async).
@@ -1475,9 +1531,87 @@ class MessageDispatchEngine:
1475
1531
 
1476
1532
  .. versionchanged:: 0.5.0
1477
1533
  Added support for context-aware dispatchers via ``node_kind``.
1534
+
1535
+ .. versionchanged:: 0.2.6
1536
+ Added binding resolution before handler execution (OMN-1518).
1478
1537
  """
1479
1538
  dispatcher = entry.dispatcher
1480
1539
 
1540
+ # =================================================================
1541
+ # Binding Resolution Phase (OMN-1518)
1542
+ # =================================================================
1543
+ # ALWAYS materialize envelope to dict format for consistent dispatcher API.
1544
+ # INVARIANT: Original envelope is NEVER mutated.
1545
+ resolved_bindings: dict[str, JsonType] = {}
1546
+
1547
+ if entry.operation_bindings is not None:
1548
+ # Extract correlation_id FIRST for error context and tracing
1549
+ correlation_id = self._extract_correlation_id(envelope)
1550
+
1551
+ # Extract operation name from envelope for binding lookup (fail-fast)
1552
+ operation = self._extract_operation_from_envelope(envelope, correlation_id)
1553
+
1554
+ # Create context for binding resolution (see constants.VALID_CONTEXT_PATHS)
1555
+ dispatch_context: dict[str, object] = {
1556
+ "now_iso": datetime.now(UTC).isoformat(),
1557
+ "dispatcher_id": entry.dispatcher_id,
1558
+ "correlation_id": correlation_id,
1559
+ }
1560
+
1561
+ # Check for declared additional_context_paths that are not provided
1562
+ # This is a CONTRACT: if handler declares additional_context_paths,
1563
+ # the dispatch engine should provide them. Log warning if missing.
1564
+ declared_additional_paths = (
1565
+ entry.operation_bindings.additional_context_paths
1566
+ )
1567
+ if declared_additional_paths:
1568
+ missing_paths = [
1569
+ path
1570
+ for path in declared_additional_paths
1571
+ if path not in dispatch_context
1572
+ ]
1573
+ if missing_paths:
1574
+ self._logger.warning(
1575
+ "Dispatcher '%s' declares additional_context_paths %s "
1576
+ "but these are not provided in dispatch context. "
1577
+ "Bindings using these paths will resolve to None unless "
1578
+ "they have defaults. correlation_id=%s",
1579
+ entry.dispatcher_id,
1580
+ missing_paths,
1581
+ correlation_id,
1582
+ )
1583
+
1584
+ # Resolve all bindings for this operation
1585
+ resolution = self._binding_resolver.resolve(
1586
+ operation=operation,
1587
+ bindings_subcontract=entry.operation_bindings,
1588
+ envelope=envelope,
1589
+ context=dispatch_context,
1590
+ correlation_id=correlation_id,
1591
+ )
1592
+
1593
+ if not resolution.success:
1594
+ # Fail fast on binding resolution failure
1595
+ raise BindingResolutionError(
1596
+ f"Binding resolution failed: {resolution.error}",
1597
+ operation_name=resolution.operation_name,
1598
+ parameter_name="unknown",
1599
+ expression="unknown",
1600
+ correlation_id=correlation_id,
1601
+ )
1602
+
1603
+ # dict() creates a shallow copy to avoid mutating the resolution result
1604
+ resolved_bindings = dict(resolution.resolved_parameters)
1605
+
1606
+ # ALWAYS materialize envelope with bindings (never mutate original)
1607
+ # Empty dict for __bindings if no bindings configured
1608
+ envelope_for_handler: dict[str, JsonType] = (
1609
+ self._materialize_envelope_with_bindings(envelope, resolved_bindings, topic)
1610
+ )
1611
+
1612
+ # =================================================================
1613
+ # Context Creation Phase
1614
+ # =================================================================
1481
1615
  # Create context ONLY if both conditions are met:
1482
1616
  # 1. node_kind is set (time injection rules apply)
1483
1617
  # 2. dispatcher accepts context (will actually use it)
@@ -1487,6 +1621,9 @@ class MessageDispatchEngine:
1487
1621
  if entry.node_kind is not None and entry.accepts_context:
1488
1622
  context = self._create_context_for_entry(entry, envelope)
1489
1623
 
1624
+ # =================================================================
1625
+ # Dispatcher Execution Phase
1626
+ # =================================================================
1490
1627
  # Check if dispatcher is async
1491
1628
  # Note: context is only non-None when entry.accepts_context is True,
1492
1629
  # so checking `context is not None` is sufficient to determine whether
@@ -1495,9 +1632,9 @@ class MessageDispatchEngine:
1495
1632
  if context is not None:
1496
1633
  # NOTE: Dispatcher signature varies - context param may be optional.
1497
1634
  # Return type depends on dispatcher implementation (dict or model).
1498
- return await dispatcher(envelope, context) # type: ignore[call-arg,no-any-return] # NOTE: dispatcher signature varies
1635
+ return await dispatcher(envelope_for_handler, context) # type: ignore[call-arg,no-any-return] # NOTE: dispatcher signature varies
1499
1636
  # NOTE: Return type depends on dispatcher implementation (dict or model).
1500
- return await dispatcher(envelope) # type: ignore[no-any-return] # NOTE: dispatcher return type varies
1637
+ return await dispatcher(envelope_for_handler) # type: ignore[no-any-return] # NOTE: dispatcher return type varies
1501
1638
  else:
1502
1639
  # Sync dispatcher execution via ThreadPoolExecutor
1503
1640
  # -----------------------------------------------
@@ -1515,24 +1652,26 @@ class MessageDispatchEngine:
1515
1652
  sync_ctx_dispatcher = cast(
1516
1653
  "_SyncContextAwareDispatcherFunc", dispatcher
1517
1654
  )
1655
+ # NOTE: run_in_executor arg-type check fails because envelope_for_handler
1656
+ # is dict[str, JsonType] but dispatcher expects dict[str, object].
1657
+ # JsonType is a subset of object, so this is safe at runtime.
1518
1658
  return await loop.run_in_executor(
1519
1659
  None,
1520
- sync_ctx_dispatcher,
1521
- # NOTE: run_in_executor expects positional args as *args,
1522
- # type checker cannot verify generic envelope type matches dispatcher.
1523
- envelope, # type: ignore[arg-type] # NOTE: generic envelope type erasure
1660
+ sync_ctx_dispatcher, # type: ignore[arg-type]
1661
+ envelope_for_handler,
1524
1662
  context,
1525
1663
  )
1526
1664
  else:
1527
1665
  # Cast to sync-only type - safe because iscoroutinefunction check above
1528
1666
  # guarantees this branch only executes for non-async callables
1529
1667
  sync_dispatcher = cast("_SyncDispatcherFunc", dispatcher)
1668
+ # NOTE: run_in_executor arg-type check fails because envelope_for_handler
1669
+ # is dict[str, JsonType] but dispatcher expects dict[str, object].
1670
+ # JsonType is a subset of object, so this is safe at runtime.
1530
1671
  return await loop.run_in_executor(
1531
1672
  None,
1532
- sync_dispatcher,
1533
- # NOTE: run_in_executor expects positional args as *args,
1534
- # type checker cannot verify generic envelope type matches dispatcher.
1535
- envelope, # type: ignore[arg-type] # NOTE: generic envelope type erasure
1673
+ sync_dispatcher, # type: ignore[arg-type]
1674
+ envelope_for_handler,
1536
1675
  )
1537
1676
 
1538
1677
  def _create_context_for_entry(
@@ -1684,6 +1823,410 @@ class MessageDispatchEngine:
1684
1823
  )
1685
1824
  return False
1686
1825
 
1826
+ def _extract_operation_from_envelope(
1827
+ self, envelope: object, correlation_id: UUID | None = None
1828
+ ) -> str:
1829
+ """Extract operation name from envelope.
1830
+
1831
+ Supports both dict-based envelopes and Pydantic model envelopes
1832
+ with an ``operation`` attribute.
1833
+
1834
+ Args:
1835
+ envelope: Event envelope (dict or Pydantic model).
1836
+ correlation_id: Optional correlation ID for error context.
1837
+
1838
+ Returns:
1839
+ Operation name string.
1840
+
1841
+ Raises:
1842
+ BindingResolutionError: When operation cannot be extracted. This
1843
+ error is raised in the following cases:
1844
+
1845
+ 1. **Dict envelope without ``operation`` key**: The envelope is a dict
1846
+ but does not contain an ``"operation"`` key.
1847
+ 2. **Model envelope without ``operation`` attribute**: The envelope is
1848
+ a Pydantic model or object but lacks an ``operation`` attribute.
1849
+ 3. **Empty/None operation value**: The operation key/attribute exists
1850
+ but has a None or falsy value.
1851
+
1852
+ Fail-Fast Behavior:
1853
+ This method implements fail-fast semantics. If an operation cannot be
1854
+ extracted, it raises ``BindingResolutionError`` immediately rather than
1855
+ returning a fallback value. This ensures:
1856
+
1857
+ - Early detection of misconfigured envelopes
1858
+ - Clear error messages with diagnostic context
1859
+ - No silent failures from attempting to resolve bindings for "unknown" operation
1860
+
1861
+ Note:
1862
+ If ``entry.operation_bindings`` is ``None`` (no bindings configured),
1863
+ this method is not called at all, avoiding unnecessary extraction.
1864
+
1865
+ .. versionadded:: 0.2.6
1866
+ Added as part of OMN-1518 - Declarative operation bindings.
1867
+
1868
+ .. versionchanged:: 0.2.7
1869
+ Changed from fallback-to-unknown to fail-fast behavior.
1870
+ """
1871
+ # Dict-based envelopes (common for Kafka/JSON payloads)
1872
+ if isinstance(envelope, dict):
1873
+ operation = envelope.get("operation")
1874
+ if operation is not None:
1875
+ return str(operation)
1876
+ # NOTE: Do not log envelope.keys() - may contain sensitive field names
1877
+ raise BindingResolutionError(
1878
+ "Operation extraction failed: dict envelope missing 'operation' key. "
1879
+ f"Ensure envelope contains 'operation' key (found {len(envelope)} keys).",
1880
+ operation_name="extraction_failed",
1881
+ parameter_name="operation",
1882
+ expression="envelope['operation']",
1883
+ missing_segment="operation",
1884
+ correlation_id=correlation_id,
1885
+ )
1886
+
1887
+ # Pydantic model or object with operation attribute
1888
+ if hasattr(envelope, "operation"):
1889
+ operation = getattr(envelope, "operation", None)
1890
+ if operation is not None:
1891
+ return str(operation)
1892
+ raise BindingResolutionError(
1893
+ "Operation extraction failed: envelope has 'operation' attribute "
1894
+ f"but value is None or falsy. Envelope type: {type(envelope).__name__}",
1895
+ operation_name="extraction_failed",
1896
+ parameter_name="operation",
1897
+ expression="envelope.operation",
1898
+ missing_segment="operation",
1899
+ correlation_id=correlation_id,
1900
+ )
1901
+
1902
+ # No operation attribute at all
1903
+ raise BindingResolutionError(
1904
+ "Operation extraction failed: envelope has no 'operation' attribute. "
1905
+ f"Envelope type: {type(envelope).__name__}",
1906
+ operation_name="extraction_failed",
1907
+ parameter_name="operation",
1908
+ expression="envelope.operation or envelope['operation']",
1909
+ missing_segment="operation",
1910
+ correlation_id=correlation_id,
1911
+ )
1912
+
1913
+ def _extract_correlation_id(self, envelope: object) -> UUID | None:
1914
+ """Extract correlation_id from envelope.
1915
+
1916
+ Supports both dict-based envelopes and Pydantic model envelopes
1917
+ with a ``correlation_id`` attribute. Handles both UUID and string values.
1918
+
1919
+ Args:
1920
+ envelope: Event envelope (dict or Pydantic model).
1921
+
1922
+ Returns:
1923
+ UUID correlation_id if found and valid, None otherwise.
1924
+
1925
+ .. versionadded:: 0.2.6
1926
+ Added as part of OMN-1518 - Declarative operation bindings.
1927
+ """
1928
+ cid: object = None
1929
+ if isinstance(envelope, dict):
1930
+ cid = envelope.get("correlation_id")
1931
+ elif hasattr(envelope, "correlation_id"):
1932
+ cid = getattr(envelope, "correlation_id", None)
1933
+
1934
+ if isinstance(cid, UUID):
1935
+ return cid
1936
+ if isinstance(cid, str):
1937
+ try:
1938
+ return UUID(cid)
1939
+ except ValueError:
1940
+ return None
1941
+ return None
1942
+
1943
+ def _materialize_envelope_with_bindings(
1944
+ self,
1945
+ original_envelope: object,
1946
+ resolved_bindings: dict[str, JsonType],
1947
+ topic: str,
1948
+ ) -> dict[str, JsonType]:
1949
+ """Create new JSON-safe envelope with __bindings namespace.
1950
+
1951
+ INVARIANT: original_envelope is NEVER mutated.
1952
+ INVARIANT: All output values are JSON-serializable.
1953
+
1954
+ This method ALWAYS creates a new dict containing:
1955
+ - ``payload``: Event payload as JSON-safe dict
1956
+ - ``__bindings``: Resolved binding parameters (empty dict if no bindings)
1957
+ - ``__debug_trace``: Serialized trace metadata snapshot (debug only)
1958
+
1959
+ The dispatch boundary is a **serialization boundary**. All data crossing
1960
+ this layer must be transport-safe (JSON-serializable) to enable:
1961
+ - Event replay from logs or Kafka
1962
+ - Distributed dispatch across processes
1963
+ - Observability tooling (logging, tracing, dashboards)
1964
+
1965
+ Warning:
1966
+ ``__debug_trace`` is provided ONLY for debugging and observability.
1967
+ It is a serialized snapshot of trace metadata, NOT the live envelope.
1968
+
1969
+ **DO NOT**:
1970
+ - Use ``__debug_trace`` for business logic
1971
+ - Assume ``__debug_trace`` reflects complete envelope state
1972
+ - Depend on specific fields being present
1973
+
1974
+ Args:
1975
+ original_envelope: Original event envelope (dict or model).
1976
+ resolved_bindings: JSON-safe resolved binding parameters (may be empty).
1977
+ topic: The topic this message was received on.
1978
+
1979
+ Returns:
1980
+ New dict conforming to ModelMaterializedDispatch schema.
1981
+ All values are JSON-serializable (transport-safe).
1982
+
1983
+ Example:
1984
+ >>> materialized = engine._materialize_envelope_with_bindings(
1985
+ ... original_envelope={"payload": {"sql": "SELECT 1"}},
1986
+ ... resolved_bindings={"sql": "SELECT 1", "limit": 100},
1987
+ ... topic="dev.db.commands.v1",
1988
+ ... )
1989
+ >>> materialized["__bindings"]
1990
+ {'sql': 'SELECT 1', 'limit': 100}
1991
+
1992
+ .. versionadded:: 0.2.6
1993
+ Added as part of OMN-1518 - Declarative operation bindings.
1994
+
1995
+ .. versionchanged:: 0.2.8
1996
+ Changed to strict JSON-safe contract:
1997
+ - Payload is serialized to dict (Pydantic models call model_dump)
1998
+ - Bindings values are serialized (UUIDs/datetimes → strings)
1999
+ - __debug_original_envelope replaced with __debug_trace snapshot
2000
+ - Return type changed to dict[str, JsonType]
2001
+ """
2002
+ # Extract and serialize payload to JSON-safe dict
2003
+ payload_json = self._serialize_payload(original_envelope)
2004
+
2005
+ # Serialize bindings to JSON-safe values
2006
+ bindings_json = self._serialize_bindings(resolved_bindings)
2007
+
2008
+ # Create debug trace snapshot (serialized, non-authoritative)
2009
+ debug_trace = self._create_debug_trace_snapshot(original_envelope, topic)
2010
+
2011
+ # Build materialized dict conforming to ModelMaterializedDispatch schema
2012
+ # NOTE: debug_trace is dict[str, str | None] which is a subset of JsonType
2013
+ materialized: dict[str, JsonType] = {
2014
+ "payload": payload_json,
2015
+ "__bindings": bindings_json,
2016
+ "__debug_trace": cast("JsonType", debug_trace),
2017
+ }
2018
+
2019
+ # Validate against schema to enforce contract invariants
2020
+ # This catches shape drift and provides clear error messages
2021
+ ModelMaterializedDispatch.model_validate(materialized)
2022
+
2023
+ return materialized
2024
+
2025
+ def _serialize_payload(self, original_envelope: object) -> JsonType:
2026
+ """Extract and serialize payload to JSON-safe dict.
2027
+
2028
+ Handlers that need typed Pydantic models should hydrate locally:
2029
+ ``ModelFoo.model_validate(dispatch["payload"])``
2030
+
2031
+ Args:
2032
+ original_envelope: Original event envelope (dict or model).
2033
+
2034
+ Returns:
2035
+ JSON-safe payload (dict for complex types, wrapped for primitives).
2036
+ """
2037
+ # Extract original payload
2038
+ original_payload: object
2039
+ if isinstance(original_envelope, dict):
2040
+ original_payload = original_envelope.get("payload", original_envelope)
2041
+ elif hasattr(original_envelope, "payload"):
2042
+ original_payload = getattr(original_envelope, "payload", original_envelope)
2043
+ else:
2044
+ original_payload = original_envelope
2045
+
2046
+ # Serialize to JSON-safe format
2047
+ if hasattr(original_payload, "model_dump"):
2048
+ # Pydantic model - serialize to dict with JSON mode
2049
+ # NOTE: model_dump returns Any, but we know it's JSON-compatible
2050
+ return original_payload.model_dump(mode="json") # type: ignore[union-attr, no-any-return]
2051
+ elif isinstance(original_payload, dict):
2052
+ # Dict - recursively serialize values
2053
+ return self._serialize_dict_values(original_payload)
2054
+ elif isinstance(original_payload, (str, int, float, bool, type(None))):
2055
+ # JSON primitive - wrap to maintain dict structure
2056
+ # NOTE: This is a last-resort escape hatch. Prefer dict payloads.
2057
+ return {"_raw": original_payload}
2058
+ elif isinstance(original_payload, list):
2059
+ # List - recursively serialize elements
2060
+ return [self._serialize_value(item) for item in original_payload]
2061
+ elif hasattr(original_payload, "__dict__"):
2062
+ # Plain Python object with attributes - serialize its __dict__
2063
+ # This handles domain objects that aren't Pydantic models
2064
+ self._logger.warning(
2065
+ "Serializing payload via __dict__ fallback for type %s. "
2066
+ "Consider using a Pydantic model for explicit serialization control.",
2067
+ type(original_payload).__name__,
2068
+ )
2069
+ return self._serialize_dict_values(vars(original_payload))
2070
+ else:
2071
+ # Unknown type - attempt string conversion
2072
+ self._logger.warning(
2073
+ "Serializing payload via string fallback for type %s. "
2074
+ "This may indicate a configuration error - payload types should be "
2075
+ "Pydantic models, dicts, or JSON primitives.",
2076
+ type(original_payload).__name__,
2077
+ )
2078
+ return {"_raw": str(original_payload)}
2079
+
2080
+ def _serialize_bindings(self, bindings: dict[str, JsonType]) -> dict[str, JsonType]:
2081
+ """Ensure binding values are JSON-safe (idempotent serialization).
2082
+
2083
+ When bindings are already JSON-safe (from OperationBindingResolver),
2084
+ this method is effectively a no-op but provides defensive serialization.
2085
+
2086
+ Args:
2087
+ bindings: Dict of resolved binding parameters (already JSON-safe).
2088
+
2089
+ Returns:
2090
+ Dict with JSON-safe values.
2091
+ """
2092
+ return {key: self._serialize_value(value) for key, value in bindings.items()}
2093
+
2094
+ def _serialize_value(self, value: object) -> JsonType:
2095
+ """Serialize a single value to JSON-safe type.
2096
+
2097
+ Args:
2098
+ value: Any value to serialize.
2099
+
2100
+ Returns:
2101
+ JSON-safe representation.
2102
+ """
2103
+ if value is None or isinstance(value, (str, int, float, bool)):
2104
+ return value # type: ignore[return-value]
2105
+ elif isinstance(value, UUID):
2106
+ return str(value)
2107
+ elif isinstance(value, datetime):
2108
+ return value.isoformat()
2109
+ elif hasattr(value, "model_dump"):
2110
+ # Pydantic model - model_dump returns Any, but we know it's JSON-compatible
2111
+ return value.model_dump(mode="json") # type: ignore[union-attr, no-any-return]
2112
+ elif isinstance(value, dict):
2113
+ return self._serialize_dict_values(value)
2114
+ elif isinstance(value, list):
2115
+ return [self._serialize_value(item) for item in value]
2116
+ else:
2117
+ # Unknown type - string conversion
2118
+ return str(value)
2119
+
2120
+ def _serialize_dict_values(self, d: dict[str, object]) -> dict[str, JsonType]:
2121
+ """Recursively serialize dict values."""
2122
+ return {key: self._serialize_value(value) for key, value in d.items()}
2123
+
2124
+ def _create_debug_trace_snapshot(
2125
+ self, original_envelope: object, topic: str
2126
+ ) -> dict[str, str | None]:
2127
+ """Create serialized trace metadata snapshot.
2128
+
2129
+ This snapshot is for debugging and observability ONLY.
2130
+ It is NOT authoritative and should NOT be used for business logic.
2131
+
2132
+ Args:
2133
+ original_envelope: Original event envelope.
2134
+ topic: The topic this message was received on.
2135
+
2136
+ Returns:
2137
+ Dict with serialized trace metadata (all strings or None).
2138
+ """
2139
+ # Extract trace fields safely (any missing field → None)
2140
+ event_type: str | None = None
2141
+ correlation_id: str | None = None
2142
+ trace_id: str | None = None
2143
+ causation_id: str | None = None
2144
+ timestamp: str | None = None
2145
+ partition_key: str | None = None
2146
+
2147
+ if isinstance(original_envelope, dict):
2148
+ event_type = self._safe_str(original_envelope.get("event_type"))
2149
+ correlation_id = self._safe_str(original_envelope.get("correlation_id"))
2150
+ trace_id = self._safe_str(original_envelope.get("trace_id"))
2151
+ causation_id = self._safe_str(original_envelope.get("causation_id"))
2152
+ timestamp = self._safe_str(original_envelope.get("timestamp"))
2153
+ partition_key = self._safe_str(original_envelope.get("partition_key"))
2154
+ else:
2155
+ # Pydantic model or other object - use getattr
2156
+ # All fields go through _safe_str to handle non-string types gracefully
2157
+ event_type = self._safe_str(getattr(original_envelope, "event_type", None))
2158
+ correlation_id = self._safe_str(
2159
+ getattr(original_envelope, "correlation_id", None)
2160
+ )
2161
+ trace_id = self._safe_str(getattr(original_envelope, "trace_id", None))
2162
+ causation_id = self._safe_str(
2163
+ getattr(original_envelope, "causation_id", None)
2164
+ )
2165
+ timestamp = self._safe_str(getattr(original_envelope, "timestamp", None))
2166
+ partition_key = self._safe_str(
2167
+ getattr(original_envelope, "partition_key", None)
2168
+ )
2169
+
2170
+ # Build snapshot using the model for validation
2171
+ snapshot = ModelDebugTraceSnapshot(
2172
+ event_type=event_type,
2173
+ correlation_id=correlation_id,
2174
+ trace_id=trace_id,
2175
+ causation_id=causation_id,
2176
+ topic=topic,
2177
+ timestamp=timestamp,
2178
+ partition_key=partition_key,
2179
+ )
2180
+
2181
+ return snapshot.model_dump()
2182
+
2183
+ def _safe_str(self, value: object) -> str | None:
2184
+ """Safely convert value to string or None.
2185
+
2186
+ This method is defensive - it only returns a string if the value
2187
+ can be meaningfully converted. Mock objects and other test artifacts
2188
+ are filtered out to avoid polluting trace snapshots.
2189
+ """
2190
+ if value is None:
2191
+ return None
2192
+ if isinstance(value, str):
2193
+ return value
2194
+ if isinstance(value, UUID):
2195
+ return str(value)
2196
+ if isinstance(value, datetime):
2197
+ return value.isoformat()
2198
+ # For other types, only convert if it results in a meaningful string
2199
+ # Filter out mock objects and other test artifacts
2200
+ str_value = str(value)
2201
+
2202
+ # Check for mock objects by module (most reliable)
2203
+ value_module = type(value).__module__
2204
+ if value_module.startswith("unittest.mock"):
2205
+ return None
2206
+
2207
+ # Check for common mock patterns in string representation
2208
+ mock_patterns = ("Mock", "MagicMock", "AsyncMock")
2209
+ if any(pattern in str_value for pattern in mock_patterns):
2210
+ return None
2211
+
2212
+ # Check for Python internal repr patterns (more specific than just <...>)
2213
+ # These patterns indicate non-serializable internal objects
2214
+ if str_value.startswith("<") and str_value.endswith(">"):
2215
+ internal_prefixes = (
2216
+ "<class ",
2217
+ "<function ",
2218
+ "<module ",
2219
+ "<bound method ",
2220
+ "<built-in ",
2221
+ "<coroutine ",
2222
+ "<generator ",
2223
+ "<async_generator ",
2224
+ )
2225
+ if any(str_value.startswith(prefix) for prefix in internal_prefixes):
2226
+ return None
2227
+
2228
+ return str_value
2229
+
1687
2230
  def get_structured_metrics(self) -> ModelDispatchMetrics:
1688
2231
  """
1689
2232
  Get structured dispatch metrics using Pydantic model.