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,753 @@
1
+ # SPDX-License-Identifier: MIT
2
+ # Copyright (c) 2025 OmniNode Team
3
+ """Binding expression parser and resolver for operation bindings.
4
+
5
+ This module provides:
6
+
7
+ - **BindingExpressionParser**: Parses ${source.path} expressions with guardrails
8
+ - **BindingExpressionParseError**: Typed error with error codes for parse failures
9
+ - **OperationBindingResolver**: Resolves bindings from envelope/payload/context
10
+
11
+ Expression Syntax
12
+ -----------------
13
+ Expressions follow the format: ``${source.path.to.field}``
14
+
15
+ Where:
16
+ - **source** is one of: ``payload``, ``envelope``, ``context``
17
+ - **path** is a dot-separated sequence of field names (no array indexing)
18
+
19
+ Examples::
20
+
21
+ ${payload.user.id} # Resolve from payload.user.id
22
+ ${envelope.correlation_id} # Resolve from envelope.correlation_id
23
+ ${context.now_iso} # Resolve from context.now_iso
24
+
25
+ Guardrails
26
+ ----------
27
+ The parser enforces the following guardrails:
28
+
29
+ - **Max expression length**: 256 characters
30
+ - **Max path depth**: 20 segments
31
+ - **No array indexing**: Expressions like ``${payload.items[0]}`` are rejected
32
+ - **Valid sources only**: Must be ``payload``, ``envelope``, or ``context``
33
+ - **Context path allowlist**: Context paths must be in ``VALID_CONTEXT_PATHS``
34
+
35
+ Resolution Behavior
36
+ -------------------
37
+ The resolver follows these rules:
38
+
39
+ 1. Global bindings are applied first
40
+ 2. Operation-specific bindings override globals for the same parameter
41
+ 3. Required bindings fail fast if resolved value is None
42
+ 4. Optional bindings use defaults when resolved value is None
43
+
44
+ Thread Safety
45
+ -------------
46
+ Both ``BindingExpressionParser`` and ``OperationBindingResolver`` are stateless
47
+ and thread-safe. They can be shared across concurrent requests.
48
+
49
+ .. versionadded:: 0.2.6
50
+ Created as part of OMN-1518 - Declarative operation bindings.
51
+ """
52
+
53
+ from __future__ import annotations
54
+
55
+ import logging
56
+ from enum import Enum
57
+ from typing import Final, Literal
58
+ from uuid import UUID
59
+
60
+ from pydantic import BaseModel
61
+
62
+ from omnibase_core.types import JsonType
63
+ from omnibase_infra.errors import BindingResolutionError
64
+ from omnibase_infra.models.bindings import (
65
+ DEFAULT_JSON_RECURSION_DEPTH,
66
+ EXPRESSION_PATTERN,
67
+ MAX_EXPRESSION_LENGTH,
68
+ MAX_PATH_SEGMENTS,
69
+ VALID_CONTEXT_PATHS,
70
+ VALID_SOURCES,
71
+ ModelBindingResolutionResult,
72
+ ModelOperationBindingsSubcontract,
73
+ ModelParsedBinding,
74
+ )
75
+
76
+ logger = logging.getLogger(__name__)
77
+
78
+
79
+ # =============================================================================
80
+ # Error Codes for Expression Parsing
81
+ # =============================================================================
82
+
83
+
84
+ class EnumBindingParseErrorCode(str, Enum):
85
+ """Error codes for binding expression parse failures.
86
+
87
+ These error codes are used by BindingExpressionParseError to provide
88
+ typed error classification. The codes align with the BINDING_LOADER_0xx
89
+ error codes used by the operation bindings loader.
90
+
91
+ .. versionadded:: 0.2.6
92
+ """
93
+
94
+ EXPRESSION_MALFORMED = "BINDING_LOADER_010"
95
+ """Expression syntax is invalid (missing ${}, wrong delimiters, array access)."""
96
+
97
+ INVALID_SOURCE = "BINDING_LOADER_011"
98
+ """Source is not one of: payload, envelope, context."""
99
+
100
+ PATH_TOO_DEEP = "BINDING_LOADER_012"
101
+ """Path exceeds MAX_PATH_SEGMENTS (20 segments)."""
102
+
103
+ EXPRESSION_TOO_LONG = "BINDING_LOADER_013"
104
+ """Expression exceeds MAX_EXPRESSION_LENGTH (256 characters)."""
105
+
106
+ EMPTY_PATH_SEGMENT = "BINDING_LOADER_014"
107
+ """Path contains empty segment (e.g., ${payload..field})."""
108
+
109
+ INVALID_CONTEXT_PATH = "BINDING_LOADER_016"
110
+ """Context path is not in VALID_CONTEXT_PATHS allowlist."""
111
+
112
+
113
+ class BindingExpressionParseError(ValueError):
114
+ """Error raised when binding expression parsing fails.
115
+
116
+ This exception provides typed error codes for different parse failure
117
+ scenarios, enabling callers to handle errors based on error code rather
118
+ than parsing error message strings.
119
+
120
+ Attributes:
121
+ error_code: The specific error code identifying the failure type.
122
+ expression: The expression that failed to parse.
123
+ message: Human-readable error message.
124
+
125
+ Example:
126
+ >>> try:
127
+ ... parser.parse("${invalid.path}")
128
+ ... except BindingExpressionParseError as e:
129
+ ... if e.error_code == EnumBindingParseErrorCode.INVALID_SOURCE:
130
+ ... # Handle invalid source
131
+ ... pass
132
+
133
+ .. versionadded:: 0.2.6
134
+ """
135
+
136
+ def __init__(
137
+ self,
138
+ message: str,
139
+ error_code: EnumBindingParseErrorCode,
140
+ expression: str,
141
+ ) -> None:
142
+ """Initialize the parse error.
143
+
144
+ Args:
145
+ message: Human-readable error description.
146
+ error_code: Typed error code identifying the failure type.
147
+ expression: The expression that failed to parse.
148
+ """
149
+ super().__init__(message)
150
+ self.error_code = error_code
151
+ self.expression = expression
152
+ self.message = message
153
+
154
+ def __str__(self) -> str:
155
+ """Return the error message."""
156
+ return self.message
157
+
158
+
159
+ # =============================================================================
160
+ # JSON Recursion Depth Limit
161
+ # =============================================================================
162
+
163
+ # NOTE: The default value (DEFAULT_JSON_RECURSION_DEPTH = 100) is now imported
164
+ # from omnibase_infra.models.bindings.constants. This enables per-contract
165
+ # configuration via ModelOperationBindingsSubcontract.max_json_recursion_depth.
166
+ #
167
+ # The configurable range is [10, 1000] with sensible defaults:
168
+ # - MIN_JSON_RECURSION_DEPTH = 10 (lower values too restrictive)
169
+ # - MAX_JSON_RECURSION_DEPTH = 1000 (higher values risk stack overflow)
170
+ # - DEFAULT_JSON_RECURSION_DEPTH = 100 (handles normal JSON structures)
171
+ #
172
+ # .. versionchanged:: 0.2.7
173
+ # Made configurable via contract.yaml. See OMN-1518.
174
+
175
+
176
+ # =============================================================================
177
+ # JSON Compatibility Validation
178
+ # =============================================================================
179
+
180
+
181
+ def _is_json_compatible(
182
+ value: object,
183
+ max_depth: int = DEFAULT_JSON_RECURSION_DEPTH,
184
+ ) -> bool:
185
+ """Check if a value is JSON-compatible.
186
+
187
+ JSON-compatible values are those that can be serialized to JSON:
188
+ - None
189
+ - Primitives: str, int, float, bool
190
+ - UUID (serializes to string)
191
+ - list (with recursively JSON-compatible elements)
192
+ - dict with str keys (with recursively JSON-compatible values)
193
+
194
+ This function performs recursive validation for nested structures.
195
+ It guards against infinite recursion by limiting depth to
196
+ ``max_depth`` levels (default: DEFAULT_JSON_RECURSION_DEPTH = 100).
197
+
198
+ Args:
199
+ value: The value to check for JSON compatibility.
200
+ max_depth: Maximum recursion depth for validation. Defaults to
201
+ DEFAULT_JSON_RECURSION_DEPTH (100). Can be configured per-contract
202
+ via ModelOperationBindingsSubcontract.max_json_recursion_depth.
203
+
204
+ Returns:
205
+ True if the value is JSON-compatible, False otherwise.
206
+
207
+ Examples:
208
+ >>> _is_json_compatible(None)
209
+ True
210
+ >>> _is_json_compatible("hello")
211
+ True
212
+ >>> _is_json_compatible({"key": [1, 2, 3]})
213
+ True
214
+ >>> from uuid import UUID
215
+ >>> _is_json_compatible(UUID("12345678-1234-5678-1234-567812345678"))
216
+ True
217
+ >>> _is_json_compatible(object())
218
+ False
219
+ >>> _is_json_compatible({"key": lambda x: x})
220
+ False
221
+
222
+ # With custom depth limit
223
+ >>> _is_json_compatible({"a": {"b": {"c": 1}}}, max_depth=50)
224
+ True
225
+
226
+ .. versionadded:: 0.2.6
227
+ .. versionchanged:: 0.2.7
228
+ Added max_depth parameter for per-contract configuration.
229
+ """
230
+ return _is_json_compatible_recursive(value, depth=0, max_depth=max_depth)
231
+
232
+
233
+ def _is_json_compatible_recursive(
234
+ value: object,
235
+ depth: int,
236
+ max_depth: int = DEFAULT_JSON_RECURSION_DEPTH,
237
+ ) -> bool:
238
+ """Recursive implementation of JSON compatibility check.
239
+
240
+ Args:
241
+ value: The value to check.
242
+ depth: Current recursion depth for overflow protection.
243
+ max_depth: Maximum allowed recursion depth.
244
+
245
+ Returns:
246
+ True if the value is JSON-compatible, False otherwise.
247
+ """
248
+ # Depth guard to prevent stack overflow on pathological inputs
249
+ if depth > max_depth:
250
+ return False
251
+
252
+ # None is JSON-compatible (maps to JSON null)
253
+ if value is None:
254
+ return True
255
+
256
+ # JSON primitives
257
+ if isinstance(value, (str, int, float, bool)):
258
+ # Note: bool must be checked with isinstance since bool is a subclass of int
259
+ return True
260
+
261
+ # UUID is JSON-compatible (serializes to string)
262
+ if isinstance(value, UUID):
263
+ return True
264
+
265
+ # List: all elements must be JSON-compatible
266
+ if isinstance(value, list):
267
+ return all(
268
+ _is_json_compatible_recursive(item, depth + 1, max_depth) for item in value
269
+ )
270
+
271
+ # Dict: keys must be str, values must be JSON-compatible
272
+ if isinstance(value, dict):
273
+ return all(
274
+ isinstance(k, str)
275
+ and _is_json_compatible_recursive(v, depth + 1, max_depth)
276
+ for k, v in value.items()
277
+ )
278
+
279
+ # Anything else (objects, callables, etc.) is not JSON-compatible
280
+ return False
281
+
282
+
283
+ # =============================================================================
284
+ # BindingExpressionParser
285
+ # =============================================================================
286
+
287
+
288
+ class BindingExpressionParser:
289
+ """Parse ${source.path} expressions with guardrails.
290
+
291
+ This parser validates and decomposes binding expressions into their
292
+ constituent parts: source and path segments. It enforces all guardrails
293
+ at parse time to fail fast on invalid expressions.
294
+
295
+ Guardrails enforced (default values, can be overridden per-contract):
296
+ - Max expression length: 256 characters (configurable: 32-1024)
297
+ - Max path depth: 20 segments (configurable: 3-50)
298
+ - No array indexing (``[0]``, ``[*]``)
299
+ - Source must be: ``payload`` | ``envelope`` | ``context``
300
+ - Context paths must be in ``VALID_CONTEXT_PATHS`` or additional_context_paths
301
+
302
+ This class is stateless and thread-safe.
303
+
304
+ Example:
305
+ >>> parser = BindingExpressionParser()
306
+ >>> source, path = parser.parse("${payload.user.id}")
307
+ >>> source
308
+ 'payload'
309
+ >>> path
310
+ ('user', 'id')
311
+
312
+ # With custom limits
313
+ >>> source, path = parser.parse(
314
+ ... "${payload.very.deep.nested.path}",
315
+ ... max_expression_length=512,
316
+ ... max_path_segments=30,
317
+ ... )
318
+
319
+ .. versionadded:: 0.2.6
320
+ .. versionchanged:: 0.2.7
321
+ Added max_expression_length, max_path_segments, and additional_context_paths
322
+ parameters for per-contract guardrail overrides.
323
+ """
324
+
325
+ def parse(
326
+ self,
327
+ expression: str,
328
+ max_expression_length: int | None = None,
329
+ max_path_segments: int | None = None,
330
+ additional_context_paths: frozenset[str] | None = None,
331
+ ) -> tuple[Literal["payload", "envelope", "context"], tuple[str, ...]]:
332
+ """Parse expression into (source, path_segments).
333
+
334
+ Args:
335
+ expression: Expression in ``${source.path.to.field}`` format.
336
+ max_expression_length: Override default expression length limit.
337
+ If None, uses MAX_EXPRESSION_LENGTH (256).
338
+ max_path_segments: Override default path segment limit.
339
+ If None, uses MAX_PATH_SEGMENTS (20).
340
+ additional_context_paths: Additional valid context paths beyond the
341
+ base VALID_CONTEXT_PATHS set. If provided, these paths will be
342
+ accepted for context source expressions.
343
+
344
+ Returns:
345
+ Tuple of (source, path_segments) where source is one of
346
+ ``"payload"``, ``"envelope"``, ``"context"`` and path_segments
347
+ is a tuple of field names.
348
+
349
+ Raises:
350
+ BindingExpressionParseError: If expression is malformed or violates
351
+ any guardrail. The exception includes a typed ``error_code`` attribute
352
+ for programmatic error handling:
353
+
354
+ - ``EXPRESSION_TOO_LONG``: Expression exceeds max length
355
+ - ``EXPRESSION_MALFORMED``: Array access or invalid syntax
356
+ - ``INVALID_SOURCE``: Source is not valid
357
+ - ``EMPTY_PATH_SEGMENT``: Path contains empty segments
358
+ - ``PATH_TOO_DEEP``: Path exceeds max segments
359
+ - ``INVALID_CONTEXT_PATH``: Context path is not in allowlist
360
+
361
+ Example:
362
+ >>> parser = BindingExpressionParser()
363
+ >>> parser.parse("${payload.user.email}")
364
+ ('payload', ('user', 'email'))
365
+
366
+ >>> parser.parse("${context.now_iso}")
367
+ ('context', ('now_iso',))
368
+
369
+ >>> parser.parse("${payload.items[0]}") # Raises BindingExpressionParseError
370
+ Traceback (most recent call last):
371
+ ...
372
+ BindingExpressionParseError: Array access not allowed in expressions: ...
373
+
374
+ .. versionchanged:: 0.2.7
375
+ Added max_expression_length, max_path_segments, and additional_context_paths
376
+ parameters for per-contract guardrail overrides.
377
+ """
378
+ # Use configured limits or defaults
379
+ effective_max_length = (
380
+ max_expression_length
381
+ if max_expression_length is not None
382
+ else MAX_EXPRESSION_LENGTH
383
+ )
384
+ effective_max_segments = (
385
+ max_path_segments if max_path_segments is not None else MAX_PATH_SEGMENTS
386
+ )
387
+
388
+ # Guardrail: max length
389
+ if len(expression) > effective_max_length:
390
+ raise BindingExpressionParseError(
391
+ f"Expression exceeds max length ({len(expression)} > {effective_max_length})",
392
+ error_code=EnumBindingParseErrorCode.EXPRESSION_TOO_LONG,
393
+ expression=expression,
394
+ )
395
+
396
+ # Guardrail: no array access
397
+ if "[" in expression or "]" in expression:
398
+ raise BindingExpressionParseError(
399
+ f"Array access not allowed in expressions: {expression}",
400
+ error_code=EnumBindingParseErrorCode.EXPRESSION_MALFORMED,
401
+ expression=expression,
402
+ )
403
+
404
+ # Parse expression
405
+ match = EXPRESSION_PATTERN.match(expression)
406
+ if not match:
407
+ raise BindingExpressionParseError(
408
+ f"Invalid expression syntax: {expression}. "
409
+ f"Expected format: ${{source.path.to.field}}",
410
+ error_code=EnumBindingParseErrorCode.EXPRESSION_MALFORMED,
411
+ expression=expression,
412
+ )
413
+
414
+ source = match.group(1)
415
+ path_str = match.group(2)
416
+
417
+ # Validate source
418
+ if source not in VALID_SOURCES:
419
+ raise BindingExpressionParseError(
420
+ f"Invalid source '{source}'. Must be one of: {sorted(VALID_SOURCES)}",
421
+ error_code=EnumBindingParseErrorCode.INVALID_SOURCE,
422
+ expression=expression,
423
+ )
424
+
425
+ # Parse path segments
426
+ path_segments = tuple(path_str.split("."))
427
+
428
+ # Guardrail: no empty segments
429
+ if any(segment == "" for segment in path_segments):
430
+ raise BindingExpressionParseError(
431
+ f"Empty path segment in expression: {expression}",
432
+ error_code=EnumBindingParseErrorCode.EMPTY_PATH_SEGMENT,
433
+ expression=expression,
434
+ )
435
+
436
+ # Guardrail: max segments
437
+ if len(path_segments) > effective_max_segments:
438
+ raise BindingExpressionParseError(
439
+ f"Path exceeds max segments ({len(path_segments)} > {effective_max_segments})",
440
+ error_code=EnumBindingParseErrorCode.PATH_TOO_DEEP,
441
+ expression=expression,
442
+ )
443
+
444
+ # Validate context paths (allowlist with optional extensions)
445
+ if source == "context":
446
+ # Build effective valid context paths
447
+ effective_context_paths = VALID_CONTEXT_PATHS
448
+ if additional_context_paths:
449
+ effective_context_paths = VALID_CONTEXT_PATHS | additional_context_paths
450
+
451
+ if path_segments[0] not in effective_context_paths:
452
+ raise BindingExpressionParseError(
453
+ f"Invalid context path '{path_segments[0]}'. "
454
+ f"Must be one of: {sorted(effective_context_paths)}",
455
+ error_code=EnumBindingParseErrorCode.INVALID_CONTEXT_PATH,
456
+ expression=expression,
457
+ )
458
+
459
+ # Type narrowing: source is guaranteed to be one of the valid values
460
+ return source, path_segments # type: ignore[return-value]
461
+
462
+
463
+ # =============================================================================
464
+ # OperationBindingResolver
465
+ # =============================================================================
466
+
467
+
468
+ class OperationBindingResolver:
469
+ """Resolve operation bindings from envelope and context.
470
+
471
+ This resolver takes pre-parsed bindings (from contract loading) and
472
+ resolves them against an event envelope and optional context. It handles
473
+ global bindings, operation-specific bindings, defaults, and required
474
+ field validation.
475
+
476
+ This class is stateless and thread-safe. A single instance can be
477
+ shared across concurrent requests.
478
+
479
+ Resolution Order:
480
+ 1. Apply global_bindings first
481
+ 2. Apply operation-specific bindings (overwrite globals)
482
+ 3. Validate required fields (fail fast on None)
483
+ 4. Apply defaults for optional fields
484
+ 5. Return resolved parameters
485
+
486
+ Example:
487
+ >>> resolver = OperationBindingResolver()
488
+ >>> result = resolver.resolve(
489
+ ... operation="db.query",
490
+ ... bindings_subcontract=subcontract,
491
+ ... envelope=envelope,
492
+ ... context=context,
493
+ ... )
494
+ >>> if result:
495
+ ... execute_query(**result.resolved_parameters)
496
+
497
+ .. versionadded:: 0.2.6
498
+ """
499
+
500
+ def __init__(self) -> None:
501
+ """Initialize resolver with expression parser."""
502
+ self._parser = BindingExpressionParser()
503
+
504
+ def resolve(
505
+ self,
506
+ operation: str,
507
+ bindings_subcontract: ModelOperationBindingsSubcontract,
508
+ envelope: object,
509
+ context: object | None,
510
+ correlation_id: UUID | None = None,
511
+ ) -> ModelBindingResolutionResult:
512
+ """Resolve all bindings for an operation.
513
+
514
+ Resolution order:
515
+ 1. Apply global_bindings first
516
+ 2. Apply operation-specific bindings (overwrite globals)
517
+ 3. Validate required fields
518
+ 4. Return resolved parameters or fail fast
519
+
520
+ Args:
521
+ operation: Operation name (e.g., ``"db.query"``).
522
+ bindings_subcontract: Subcontract with binding definitions
523
+ (pre-parsed from contract.yaml).
524
+ envelope: Event envelope with payload. Can be a dict or
525
+ Pydantic model with ``payload`` attribute.
526
+ context: Dispatch context (may be None). Can be a dict or
527
+ Pydantic model.
528
+ correlation_id: Request correlation for error context and
529
+ distributed tracing.
530
+
531
+ Returns:
532
+ ``ModelBindingResolutionResult`` with resolved parameters.
533
+ Check ``result.success`` or use ``if result:`` to verify
534
+ resolution succeeded.
535
+
536
+ Raises:
537
+ BindingResolutionError: If a required binding resolves to None
538
+ (fail-fast behavior). The error includes diagnostic context
539
+ including operation name, parameter name, and expression.
540
+
541
+ Example:
542
+ >>> result = resolver.resolve(
543
+ ... operation="db.query",
544
+ ... bindings_subcontract=subcontract,
545
+ ... envelope={"payload": {"sql": "SELECT 1"}},
546
+ ... context={"now_iso": "2025-01-01T00:00:00Z"},
547
+ ... )
548
+ >>> result.resolved_parameters
549
+ {'sql': 'SELECT 1', 'timestamp': '2025-01-01T00:00:00Z'}
550
+ """
551
+ resolved_parameters: dict[str, JsonType] = {}
552
+ resolved_from: dict[str, str] = {}
553
+
554
+ # Collect all bindings to process (global first, then operation-specific)
555
+ # Operation-specific bindings override globals for the same parameter
556
+ bindings_to_process: list[ModelParsedBinding] = []
557
+
558
+ if bindings_subcontract.global_bindings:
559
+ bindings_to_process.extend(bindings_subcontract.global_bindings)
560
+
561
+ operation_bindings = bindings_subcontract.bindings.get(operation, [])
562
+ bindings_to_process.extend(operation_bindings)
563
+
564
+ # Get max JSON recursion depth from contract (or use default)
565
+ max_json_depth = bindings_subcontract.max_json_recursion_depth
566
+
567
+ # Process each binding
568
+ for binding in bindings_to_process:
569
+ try:
570
+ value = self._resolve_single_binding(
571
+ binding=binding,
572
+ envelope=envelope,
573
+ context=context,
574
+ max_json_recursion_depth=max_json_depth,
575
+ )
576
+
577
+ if value is None and binding.required:
578
+ # Fail fast on missing required binding
579
+ raise BindingResolutionError(
580
+ f"Required binding '{binding.parameter_name}' resolved to None",
581
+ operation_name=operation,
582
+ parameter_name=binding.parameter_name,
583
+ expression=binding.original_expression,
584
+ missing_segment=binding.path_segments[-1]
585
+ if binding.path_segments
586
+ else None,
587
+ correlation_id=correlation_id,
588
+ )
589
+
590
+ # Use default if value is None and not required
591
+ if value is None and binding.default is not None:
592
+ value = binding.default
593
+
594
+ resolved_parameters[binding.parameter_name] = value
595
+ resolved_from[binding.parameter_name] = binding.original_expression
596
+
597
+ except BindingResolutionError:
598
+ # Re-raise BindingResolutionError without wrapping
599
+ raise
600
+ except Exception as e:
601
+ # Wrap unexpected errors with diagnostic context
602
+ raise BindingResolutionError(
603
+ f"Failed to resolve binding: {e}",
604
+ operation_name=operation,
605
+ parameter_name=binding.parameter_name,
606
+ expression=binding.original_expression,
607
+ correlation_id=correlation_id,
608
+ ) from e
609
+
610
+ return ModelBindingResolutionResult(
611
+ operation_name=operation,
612
+ resolved_parameters=resolved_parameters,
613
+ resolved_from=resolved_from,
614
+ success=True,
615
+ error=None,
616
+ )
617
+
618
+ def _resolve_single_binding(
619
+ self,
620
+ binding: ModelParsedBinding,
621
+ envelope: object,
622
+ context: object | None,
623
+ max_json_recursion_depth: int = DEFAULT_JSON_RECURSION_DEPTH,
624
+ ) -> JsonType:
625
+ """Resolve a single binding from envelope/context.
626
+
627
+ Args:
628
+ binding: Pre-parsed binding with source and path.
629
+ envelope: Event envelope (dict or Pydantic model).
630
+ context: Dispatch context (dict, Pydantic model, or None).
631
+ max_json_recursion_depth: Maximum depth for JSON compatibility
632
+ validation. Defaults to DEFAULT_JSON_RECURSION_DEPTH (100).
633
+
634
+ Returns:
635
+ Resolved value (may be None if path doesn't exist).
636
+
637
+ .. versionchanged:: 0.2.7
638
+ Added max_json_recursion_depth parameter for per-contract configuration.
639
+ """
640
+ # Get source object based on binding source
641
+ if binding.source == "payload":
642
+ source_obj = self._get_payload(envelope)
643
+ elif binding.source == "envelope":
644
+ source_obj = envelope
645
+ elif binding.source == "context":
646
+ source_obj = context
647
+ else:
648
+ # Should not happen if bindings are pre-validated
649
+ return None
650
+
651
+ if source_obj is None:
652
+ return None
653
+
654
+ # Traverse path to get value
655
+ return self._traverse_path(
656
+ source_obj,
657
+ binding.path_segments,
658
+ max_json_recursion_depth=max_json_recursion_depth,
659
+ )
660
+
661
+ def _get_payload(self, envelope: object) -> object | None:
662
+ """Extract payload from envelope.
663
+
664
+ Supports both dict-based envelopes and Pydantic model envelopes.
665
+
666
+ Args:
667
+ envelope: Event envelope.
668
+
669
+ Returns:
670
+ Payload object or None if not found.
671
+ """
672
+ if isinstance(envelope, dict):
673
+ return envelope.get("payload")
674
+ if hasattr(envelope, "payload"):
675
+ return getattr(envelope, "payload", None)
676
+ return None
677
+
678
+ def _traverse_path(
679
+ self,
680
+ obj: object,
681
+ path_segments: tuple[str, ...],
682
+ max_json_recursion_depth: int = DEFAULT_JSON_RECURSION_DEPTH,
683
+ ) -> JsonType:
684
+ """Traverse path segments to get value.
685
+
686
+ Uses ``dict.get()`` for dicts and ``getattr()`` for objects.
687
+ Returns None if any segment in the path doesn't exist.
688
+
689
+ Runtime Validation:
690
+ The returned value is validated at runtime to ensure JSON
691
+ compatibility. If the traversed value is not JSON-compatible
692
+ (e.g., a callable, custom object, or other non-serializable type),
693
+ a warning is logged and None is returned for graceful degradation.
694
+
695
+ This validation prevents non-JSON values from propagating through
696
+ the binding resolution pipeline, which would cause serialization
697
+ failures downstream.
698
+
699
+ Args:
700
+ obj: Starting object to traverse from.
701
+ path_segments: Tuple of field names to traverse.
702
+ max_json_recursion_depth: Maximum depth for JSON compatibility
703
+ validation. Defaults to DEFAULT_JSON_RECURSION_DEPTH (100).
704
+
705
+ Returns:
706
+ Value at path or None if:
707
+ - Path doesn't exist
708
+ - Value at path is not JSON-compatible (logs warning)
709
+
710
+ .. versionchanged:: 0.2.7
711
+ Added max_json_recursion_depth parameter for per-contract configuration.
712
+ """
713
+ current: object = obj
714
+
715
+ for segment in path_segments:
716
+ if current is None:
717
+ return None
718
+
719
+ if isinstance(current, dict):
720
+ current = current.get(segment)
721
+ elif isinstance(current, BaseModel):
722
+ # Pydantic models: use getattr
723
+ current = getattr(current, segment, None)
724
+ elif hasattr(current, segment):
725
+ # Generic objects with attributes
726
+ current = getattr(current, segment, None)
727
+ else:
728
+ # No way to access the segment
729
+ return None
730
+
731
+ # Runtime validation: ensure value is JSON-compatible
732
+ # The type checker sees 'object' but binding paths should only
733
+ # resolve to JSON-compatible values. We validate at runtime to
734
+ # catch cases where non-JSON values (e.g., callables, custom objects)
735
+ # are accidentally exposed through the binding path.
736
+ if not _is_json_compatible(current, max_depth=max_json_recursion_depth):
737
+ logger.warning(
738
+ "Binding path '%s' resolved to non-JSON-compatible value of type '%s'. "
739
+ "Returning None for graceful degradation.",
740
+ ".".join(path_segments),
741
+ type(current).__name__,
742
+ )
743
+ return None
744
+
745
+ return current # type: ignore[return-value]
746
+
747
+
748
+ __all__: list[str] = [
749
+ "BindingExpressionParseError",
750
+ "BindingExpressionParser",
751
+ "EnumBindingParseErrorCode",
752
+ "OperationBindingResolver",
753
+ ]