omnibase_infra 0.2.1__py3-none-any.whl → 0.2.2__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 (116) hide show
  1. omnibase_infra/__init__.py +1 -1
  2. omnibase_infra/adapters/adapter_onex_tool_execution.py +446 -0
  3. omnibase_infra/cli/commands.py +1 -1
  4. omnibase_infra/configs/widget_mapping.yaml +176 -0
  5. omnibase_infra/contracts/handlers/filesystem/handler_contract.yaml +4 -1
  6. omnibase_infra/contracts/handlers/mcp/handler_contract.yaml +4 -1
  7. omnibase_infra/errors/error_compute_registry.py +4 -1
  8. omnibase_infra/errors/error_event_bus_registry.py +4 -1
  9. omnibase_infra/errors/error_infra.py +3 -1
  10. omnibase_infra/errors/error_policy_registry.py +4 -1
  11. omnibase_infra/handlers/handler_db.py +2 -1
  12. omnibase_infra/handlers/handler_graph.py +10 -5
  13. omnibase_infra/handlers/handler_mcp.py +736 -63
  14. omnibase_infra/handlers/mixins/mixin_consul_kv.py +4 -3
  15. omnibase_infra/handlers/mixins/mixin_consul_service.py +2 -1
  16. omnibase_infra/handlers/service_discovery/handler_service_discovery_consul.py +301 -4
  17. omnibase_infra/handlers/service_discovery/models/model_service_info.py +10 -0
  18. omnibase_infra/mixins/mixin_async_circuit_breaker.py +3 -2
  19. omnibase_infra/mixins/mixin_node_introspection.py +24 -7
  20. omnibase_infra/mixins/mixin_retry_execution.py +1 -1
  21. omnibase_infra/models/handlers/__init__.py +10 -0
  22. omnibase_infra/models/handlers/model_bootstrap_handler_descriptor.py +162 -0
  23. omnibase_infra/models/handlers/model_handler_descriptor.py +15 -0
  24. omnibase_infra/models/mcp/__init__.py +15 -0
  25. omnibase_infra/models/mcp/model_mcp_contract_config.py +80 -0
  26. omnibase_infra/models/mcp/model_mcp_server_config.py +67 -0
  27. omnibase_infra/models/mcp/model_mcp_tool_definition.py +73 -0
  28. omnibase_infra/models/mcp/model_mcp_tool_parameter.py +35 -0
  29. omnibase_infra/models/registration/model_node_capabilities.py +11 -0
  30. omnibase_infra/nodes/architecture_validator/contract_architecture_validator.yaml +0 -5
  31. omnibase_infra/nodes/architecture_validator/registry/registry_infra_architecture_validator.py +17 -10
  32. omnibase_infra/nodes/effects/contract.yaml +0 -5
  33. omnibase_infra/nodes/node_registration_orchestrator/contract.yaml +7 -0
  34. omnibase_infra/nodes/node_registration_orchestrator/handlers/handler_node_introspected.py +86 -1
  35. omnibase_infra/nodes/node_registration_orchestrator/introspection_event_router.py +3 -3
  36. omnibase_infra/nodes/node_registration_orchestrator/registry/registry_infra_node_registration_orchestrator.py +9 -8
  37. omnibase_infra/nodes/node_registration_orchestrator/wiring.py +14 -13
  38. omnibase_infra/nodes/node_registration_storage_effect/contract.yaml +0 -5
  39. omnibase_infra/nodes/node_registration_storage_effect/registry/registry_infra_registration_storage.py +46 -25
  40. omnibase_infra/nodes/node_registry_effect/contract.yaml +0 -5
  41. omnibase_infra/nodes/node_registry_effect/handlers/handler_partial_retry.py +2 -1
  42. omnibase_infra/nodes/node_service_discovery_effect/registry/registry_infra_service_discovery.py +24 -19
  43. omnibase_infra/plugins/examples/plugin_json_normalizer.py +2 -2
  44. omnibase_infra/plugins/examples/plugin_json_normalizer_error_handling.py +2 -2
  45. omnibase_infra/plugins/plugin_compute_base.py +16 -2
  46. omnibase_infra/protocols/protocol_event_projector.py +1 -1
  47. omnibase_infra/runtime/__init__.py +51 -1
  48. omnibase_infra/runtime/binding_config_resolver.py +102 -37
  49. omnibase_infra/runtime/constants_notification.py +75 -0
  50. omnibase_infra/runtime/contract_handler_discovery.py +6 -1
  51. omnibase_infra/runtime/handler_bootstrap_source.py +514 -0
  52. omnibase_infra/runtime/handler_contract_config_loader.py +603 -0
  53. omnibase_infra/runtime/handler_contract_source.py +289 -167
  54. omnibase_infra/runtime/handler_plugin_loader.py +4 -2
  55. omnibase_infra/runtime/mixin_semver_cache.py +25 -1
  56. omnibase_infra/runtime/mixins/__init__.py +7 -0
  57. omnibase_infra/runtime/mixins/mixin_projector_notification_publishing.py +566 -0
  58. omnibase_infra/runtime/mixins/mixin_projector_sql_operations.py +31 -10
  59. omnibase_infra/runtime/models/__init__.py +24 -0
  60. omnibase_infra/runtime/models/model_health_check_result.py +2 -1
  61. omnibase_infra/runtime/models/model_projector_notification_config.py +171 -0
  62. omnibase_infra/runtime/models/model_transition_notification_outbox_config.py +112 -0
  63. omnibase_infra/runtime/models/model_transition_notification_outbox_metrics.py +140 -0
  64. omnibase_infra/runtime/models/model_transition_notification_publisher_metrics.py +357 -0
  65. omnibase_infra/runtime/projector_plugin_loader.py +1 -1
  66. omnibase_infra/runtime/projector_shell.py +229 -1
  67. omnibase_infra/runtime/protocols/__init__.py +10 -0
  68. omnibase_infra/runtime/registry/registry_protocol_binding.py +3 -2
  69. omnibase_infra/runtime/registry_policy.py +9 -326
  70. omnibase_infra/runtime/secret_resolver.py +4 -2
  71. omnibase_infra/runtime/service_kernel.py +10 -2
  72. omnibase_infra/runtime/service_message_dispatch_engine.py +4 -2
  73. omnibase_infra/runtime/service_runtime_host_process.py +225 -15
  74. omnibase_infra/runtime/transition_notification_outbox.py +1190 -0
  75. omnibase_infra/runtime/transition_notification_publisher.py +764 -0
  76. omnibase_infra/runtime/util_container_wiring.py +6 -5
  77. omnibase_infra/runtime/util_wiring.py +5 -1
  78. omnibase_infra/schemas/schema_transition_notification_outbox.sql +245 -0
  79. omnibase_infra/services/mcp/__init__.py +31 -0
  80. omnibase_infra/services/mcp/mcp_server_lifecycle.py +443 -0
  81. omnibase_infra/services/mcp/service_mcp_tool_discovery.py +411 -0
  82. omnibase_infra/services/mcp/service_mcp_tool_registry.py +329 -0
  83. omnibase_infra/services/mcp/service_mcp_tool_sync.py +547 -0
  84. omnibase_infra/services/registry_api/__init__.py +40 -0
  85. omnibase_infra/services/registry_api/main.py +243 -0
  86. omnibase_infra/services/registry_api/models/__init__.py +66 -0
  87. omnibase_infra/services/registry_api/models/model_capability_widget_mapping.py +38 -0
  88. omnibase_infra/services/registry_api/models/model_pagination_info.py +48 -0
  89. omnibase_infra/services/registry_api/models/model_registry_discovery_response.py +73 -0
  90. omnibase_infra/services/registry_api/models/model_registry_health_response.py +49 -0
  91. omnibase_infra/services/registry_api/models/model_registry_instance_view.py +88 -0
  92. omnibase_infra/services/registry_api/models/model_registry_node_view.py +88 -0
  93. omnibase_infra/services/registry_api/models/model_registry_summary.py +60 -0
  94. omnibase_infra/services/registry_api/models/model_response_list_instances.py +43 -0
  95. omnibase_infra/services/registry_api/models/model_response_list_nodes.py +51 -0
  96. omnibase_infra/services/registry_api/models/model_warning.py +49 -0
  97. omnibase_infra/services/registry_api/models/model_widget_defaults.py +28 -0
  98. omnibase_infra/services/registry_api/models/model_widget_mapping.py +51 -0
  99. omnibase_infra/services/registry_api/routes.py +371 -0
  100. omnibase_infra/services/registry_api/service.py +846 -0
  101. omnibase_infra/services/service_capability_query.py +4 -4
  102. omnibase_infra/services/service_health.py +3 -2
  103. omnibase_infra/services/service_timeout_emitter.py +13 -2
  104. omnibase_infra/utils/util_dsn_validation.py +1 -1
  105. omnibase_infra/validation/__init__.py +3 -19
  106. omnibase_infra/validation/contracts/security.validation.yaml +114 -0
  107. omnibase_infra/validation/infra_validators.py +35 -24
  108. omnibase_infra/validation/validation_exemptions.yaml +113 -9
  109. omnibase_infra/validation/validator_chain_propagation.py +2 -2
  110. omnibase_infra/validation/validator_runtime_shape.py +1 -1
  111. omnibase_infra/validation/validator_security.py +473 -370
  112. {omnibase_infra-0.2.1.dist-info → omnibase_infra-0.2.2.dist-info}/METADATA +2 -2
  113. {omnibase_infra-0.2.1.dist-info → omnibase_infra-0.2.2.dist-info}/RECORD +116 -74
  114. {omnibase_infra-0.2.1.dist-info → omnibase_infra-0.2.2.dist-info}/WHEEL +0 -0
  115. {omnibase_infra-0.2.1.dist-info → omnibase_infra-0.2.2.dist-info}/entry_points.txt +0 -0
  116. {omnibase_infra-0.2.1.dist-info → omnibase_infra-0.2.2.dist-info}/licenses/LICENSE +0 -0
@@ -0,0 +1,566 @@
1
+ # SPDX-License-Identifier: MIT
2
+ # Copyright (c) 2025 OmniNode Team
3
+ """Notification Publishing Mixin for Projector Implementations.
4
+
5
+ Provides notification publishing capability for projector shells. This mixin
6
+ extracts notification logic from ProjectorShell to keep the main class focused
7
+ on projection logic and under the method count limit.
8
+
9
+ Features:
10
+ - Pre-projection state fetching for from_state tracking
11
+ - Post-commit notification creation and publishing
12
+ - Correlation ID and causation ID propagation
13
+ - Configurable via ModelProjectorNotificationConfig
14
+
15
+ Architecture:
16
+ This mixin works with ProjectorShell to implement the Observer pattern:
17
+
18
+ 1. Before projection: Fetch current state from database (from_state)
19
+ 2. Execute projection (handled by ProjectorShell)
20
+ 3. After successful commit: Publish notification with from_state/to_state
21
+
22
+ ```
23
+ Event → ProjectorShell.project() → Database Commit
24
+ ↓ ↓
25
+ _fetch_current_state() _publish_transition_notification()
26
+ ↓ ↓
27
+ from_state Event Bus (to Orchestrators)
28
+ ```
29
+
30
+ See Also:
31
+ - ProjectorShell: Main projector class that uses this mixin
32
+ - TransitionNotificationPublisher: Publishes notifications to event bus
33
+ - ModelProjectorNotificationConfig: Configuration for notification behavior
34
+
35
+ Related Tickets:
36
+ - OMN-1139: Integrate TransitionNotificationPublisher with ProjectorShell
37
+
38
+ .. versionadded:: 0.8.0
39
+ Created as part of OMN-1139 notification integration.
40
+ """
41
+
42
+ from __future__ import annotations
43
+
44
+ import logging
45
+ from datetime import UTC, datetime
46
+ from typing import TYPE_CHECKING, Protocol
47
+ from uuid import UUID
48
+
49
+ import asyncpg
50
+
51
+ from omnibase_core.models.notifications import ModelStateTransitionNotification
52
+ from omnibase_core.protocols.notifications import (
53
+ ProtocolTransitionNotificationPublisher,
54
+ )
55
+ from omnibase_infra.errors import (
56
+ InfraConnectionError,
57
+ InfraTimeoutError,
58
+ InfraUnavailableError,
59
+ )
60
+ from omnibase_infra.models.projectors.util_sql_identifiers import quote_identifier
61
+ from omnibase_infra.runtime.constants_notification import FROM_STATE_INITIAL
62
+ from omnibase_infra.runtime.models.model_projector_notification_config import (
63
+ ModelProjectorNotificationConfig,
64
+ )
65
+
66
+ if TYPE_CHECKING:
67
+ from omnibase_core.models.events.model_event_envelope import ModelEventEnvelope
68
+ from omnibase_core.models.projectors import ModelProjectorContract
69
+
70
+ logger = logging.getLogger(__name__)
71
+
72
+
73
+ class ProtocolProjectorNotificationContext(Protocol):
74
+ """Protocol for projector context required by notification publishing mixin.
75
+
76
+ This protocol defines the minimum interface that a projector must
77
+ implement to use MixinProjectorNotificationPublishing.
78
+ """
79
+
80
+ @property
81
+ def _contract(self) -> ModelProjectorContract:
82
+ """The projector contract defining projection behavior."""
83
+ ...
84
+
85
+ @property
86
+ def _pool(self) -> asyncpg.Pool:
87
+ """The asyncpg connection pool for database operations."""
88
+ ...
89
+
90
+ @property
91
+ def _query_timeout(self) -> float:
92
+ """Query timeout in seconds."""
93
+ ...
94
+
95
+ @property
96
+ def projector_id(self) -> str:
97
+ """Unique identifier for the projector."""
98
+ ...
99
+
100
+ @property
101
+ def aggregate_type(self) -> str:
102
+ """Aggregate type from contract."""
103
+ ...
104
+
105
+
106
+ class MixinProjectorNotificationPublishing:
107
+ """Notification publishing mixin for projector implementations.
108
+
109
+ Provides methods for fetching previous state before projection and
110
+ publishing state transition notifications after successful commits.
111
+
112
+ This mixin expects the implementing class to provide:
113
+ - ``_contract``: ModelProjectorContract instance
114
+ - ``_pool``: asyncpg.Pool for database connections
115
+ - ``_query_timeout``: float timeout in seconds
116
+ - ``projector_id``: str identifier for logging
117
+ - ``aggregate_type``: str aggregate type from contract
118
+
119
+ Example:
120
+ >>> class ProjectorShell(MixinProjectorNotificationPublishing, ...):
121
+ ... def __init__(
122
+ ... self,
123
+ ... contract,
124
+ ... pool,
125
+ ... notification_publisher=None,
126
+ ... notification_config=None,
127
+ ... ):
128
+ ... self._contract = contract
129
+ ... self._pool = pool
130
+ ... self._notification_publisher = notification_publisher
131
+ ... self._notification_config = notification_config
132
+ ...
133
+ ... async def project(self, event, correlation_id):
134
+ ... # Fetch previous state if notifications enabled
135
+ ... from_state = await self._fetch_current_state_for_notification(
136
+ ... aggregate_id, correlation_id
137
+ ... )
138
+ ...
139
+ ... # Execute projection...
140
+ ... result = await self._execute_projection(...)
141
+ ...
142
+ ... # Publish notification if configured
143
+ ... if result.rows_affected > 0:
144
+ ... await self._publish_transition_notification(
145
+ ... event, from_state, to_state, version, correlation_id
146
+ ... )
147
+ ... return result
148
+
149
+ Warning:
150
+ **Race Condition in from_state Tracking**: The ``from_state`` value is fetched
151
+ in a separate query BEFORE the projection executes. There is a race window where
152
+ another concurrent projection could modify the state between the fetch and the
153
+ actual projection commit. In this scenario, the ``from_state`` in the notification
154
+ may not reflect the true previous state.
155
+
156
+ This is acceptable for most use cases because:
157
+
158
+ 1. Notifications are best-effort (failures are logged, not raised)
159
+ 2. Consumers should be idempotent and tolerate slight inconsistencies
160
+ 3. The ``to_state`` is always accurate (extracted from projection values)
161
+
162
+ For use cases requiring truly accurate ``from_state`` tracking, consider:
163
+
164
+ - **Database triggers**: Use PostgreSQL triggers to capture old row values atomically
165
+ - **RETURNING clause**: Modify the projection SQL to use ``UPDATE ... RETURNING``
166
+ with the old value (requires schema changes)
167
+ - **Optimistic locking**: Add version checks to detect concurrent modifications
168
+
169
+ See ``_fetch_current_state_for_notification`` for detailed race condition analysis.
170
+ """
171
+
172
+ # Type hints for expected attributes from implementing class
173
+ _contract: ModelProjectorContract
174
+ _pool: asyncpg.Pool
175
+ _query_timeout: float
176
+ _notification_publisher: ProtocolTransitionNotificationPublisher | None
177
+ _notification_config: ModelProjectorNotificationConfig | None
178
+
179
+ @property
180
+ def projector_id(self) -> str:
181
+ """Unique identifier for the projector (expected from implementing class)."""
182
+ raise NotImplementedError("projector_id must be implemented by subclass")
183
+
184
+ @property
185
+ def aggregate_type(self) -> str:
186
+ """Aggregate type from contract (expected from implementing class)."""
187
+ raise NotImplementedError("aggregate_type must be implemented by subclass")
188
+
189
+ def _is_notification_enabled(self) -> bool:
190
+ """Check if notification publishing is enabled.
191
+
192
+ Returns True only if:
193
+ - notification_publisher is configured
194
+ - notification_config is configured
195
+ - notification_config.enabled is True
196
+
197
+ Returns:
198
+ True if notifications should be published, False otherwise.
199
+ """
200
+ return self._get_notification_context() is not None
201
+
202
+ def _get_notification_context(
203
+ self,
204
+ ) -> (
205
+ tuple[ProtocolTransitionNotificationPublisher, ModelProjectorNotificationConfig]
206
+ | None
207
+ ):
208
+ """Return (publisher, config) tuple if notifications are enabled, None otherwise.
209
+
210
+ This helper consolidates the enabled check with access to both publisher and config,
211
+ reducing duplication in type-narrowing methods. The 3-way check (publisher not None,
212
+ config not None, config.enabled is True) is performed once here.
213
+
214
+ Returns:
215
+ Tuple of (publisher, config) if notifications are enabled, None otherwise.
216
+ """
217
+ if (
218
+ self._notification_publisher is not None
219
+ and self._notification_config is not None
220
+ and self._notification_config.enabled
221
+ ):
222
+ return (self._notification_publisher, self._notification_config)
223
+ return None
224
+
225
+ def _get_notification_config_if_enabled(
226
+ self,
227
+ ) -> ModelProjectorNotificationConfig | None:
228
+ """Return notification config if notifications are enabled, None otherwise.
229
+
230
+ This helper provides proper type narrowing for mypy without redundant None guards.
231
+ Delegates to ``_get_notification_context()`` for the enabled check.
232
+
233
+ Returns:
234
+ The notification config if enabled, None otherwise.
235
+ """
236
+ context = self._get_notification_context()
237
+ return context[1] if context else None
238
+
239
+ def _get_notification_publisher_if_enabled(
240
+ self,
241
+ ) -> ProtocolTransitionNotificationPublisher | None:
242
+ """Return notification publisher if notifications are enabled, None otherwise.
243
+
244
+ This helper provides proper type narrowing for mypy without redundant None guards.
245
+ Delegates to ``_get_notification_context()`` for the enabled check.
246
+
247
+ Returns:
248
+ The notification publisher if enabled, None otherwise.
249
+ """
250
+ context = self._get_notification_context()
251
+ return context[0] if context else None
252
+
253
+ async def _fetch_current_state_for_notification(
254
+ self,
255
+ aggregate_id: UUID,
256
+ correlation_id: UUID,
257
+ ) -> str | None:
258
+ """Fetch the current state value for notification tracking.
259
+
260
+ Queries the projection table to retrieve the current state value
261
+ before a projection is executed. This becomes the from_state in
262
+ the transition notification.
263
+
264
+ Args:
265
+ aggregate_id: The aggregate ID to look up.
266
+ correlation_id: Correlation ID for distributed tracing.
267
+
268
+ Returns:
269
+ The current state value as a string, or None if:
270
+ - Notifications are not enabled
271
+ - No row exists for this aggregate (new entity)
272
+ - State column value is NULL
273
+
274
+ Warning:
275
+ **Race Condition Window**: This method fetches state BEFORE the projection
276
+ executes, not atomically with it. The sequence is::
277
+
278
+ T1: _fetch_current_state_for_notification() -> reads state="pending"
279
+ T2: [Another projection commits] -> state changes to "approved"
280
+ T1: _execute_projection() -> changes state to "completed"
281
+ T1: Notification published with from_state="pending" (STALE!)
282
+
283
+ In this scenario, the actual transition was "approved" -> "completed",
284
+ but the notification reports "pending" -> "completed".
285
+
286
+ **Impact Assessment**:
287
+
288
+ - **Low impact**: Notifications are best-effort; consumers should be idempotent
289
+ - **to_state is accurate**: Always extracted from projection values, not fetched
290
+ - **Ordering preserved**: projection_version ensures consumers can order correctly
291
+
292
+ **When This Matters**:
293
+
294
+ - Audit logging requiring exact state history
295
+ - UI showing real-time state transitions
296
+ - Compliance systems requiring accurate audit trails
297
+
298
+ **Alternatives for Strict Requirements**:
299
+
300
+ 1. **Database triggers** (recommended): Create a PostgreSQL trigger that fires
301
+ BEFORE UPDATE and captures OLD.state into a session variable or audit table::
302
+
303
+ CREATE OR REPLACE FUNCTION capture_old_state()
304
+ RETURNS TRIGGER AS $$
305
+ BEGIN
306
+ PERFORM set_config('app.old_state', OLD.state, true);
307
+ RETURN NEW;
308
+ END;
309
+ $$ LANGUAGE plpgsql;
310
+
311
+ 2. **RETURNING clause**: Modify projection SQL to return old value using
312
+ a subquery or CTE (requires significant projection refactoring)::
313
+
314
+ WITH old AS (SELECT state FROM table WHERE id = $1)
315
+ UPDATE table SET state = $2 WHERE id = $1
316
+ RETURNING (SELECT state FROM old) AS old_state
317
+
318
+ 3. **Serializable isolation**: Use SERIALIZABLE transaction isolation
319
+ (significant performance impact, not recommended for high-throughput)
320
+
321
+ **Design Decision**: This implementation prioritizes simplicity and performance
322
+ over strict consistency. The race window is small (typically <10ms) and the
323
+ impact is limited to from_state accuracy in notifications.
324
+ """
325
+ config = self._get_notification_config_if_enabled()
326
+ if config is None:
327
+ return None
328
+
329
+ schema = self._contract.projection_schema
330
+ table_quoted = quote_identifier(schema.table)
331
+ state_col_quoted = quote_identifier(config.state_column)
332
+ pk_col_quoted = quote_identifier(config.aggregate_id_column)
333
+
334
+ # Build SELECT query - table/column names from trusted contract
335
+ # S608: Safe - identifiers quoted via quote_identifier(), not user input
336
+ query = (
337
+ f"SELECT {state_col_quoted} FROM {table_quoted} WHERE {pk_col_quoted} = $1" # noqa: S608
338
+ )
339
+
340
+ try:
341
+ async with self._pool.acquire() as conn:
342
+ row = await conn.fetchrow(
343
+ query, aggregate_id, timeout=self._query_timeout
344
+ )
345
+
346
+ if row is None:
347
+ logger.debug(
348
+ "No existing state found for aggregate (new entity)",
349
+ extra={
350
+ "projector_id": self.projector_id,
351
+ "aggregate_id": str(aggregate_id),
352
+ "correlation_id": str(correlation_id),
353
+ },
354
+ )
355
+ return None
356
+
357
+ state_value = row[config.state_column]
358
+ if state_value is not None:
359
+ return str(state_value)
360
+ return None
361
+
362
+ except (TimeoutError, asyncpg.PostgresError) as e:
363
+ # Log but don't fail - notifications are best-effort
364
+ # Narrowed to DB and timeout errors to avoid masking configuration errors
365
+ logger.warning(
366
+ "Failed to fetch current state for notification: %s",
367
+ str(e),
368
+ extra={
369
+ "projector_id": self.projector_id,
370
+ "aggregate_id": str(aggregate_id),
371
+ "correlation_id": str(correlation_id),
372
+ "error_type": type(e).__name__,
373
+ },
374
+ )
375
+ return None
376
+
377
+ def _extract_state_from_values(
378
+ self,
379
+ values: dict[str, object],
380
+ ) -> str | None:
381
+ """Extract the state value from projection values.
382
+
383
+ Args:
384
+ values: Column name to value mapping from value extraction.
385
+
386
+ Returns:
387
+ The state value as a string, or None if not found.
388
+ """
389
+ config = self._get_notification_config_if_enabled()
390
+ if config is None:
391
+ return None
392
+
393
+ state_value = values.get(config.state_column)
394
+ if state_value is not None:
395
+ return str(state_value)
396
+ return None
397
+
398
+ def _extract_aggregate_id_from_values(
399
+ self,
400
+ values: dict[str, object],
401
+ ) -> UUID | None:
402
+ """Extract the aggregate ID from projection values.
403
+
404
+ Args:
405
+ values: Column name to value mapping from value extraction.
406
+
407
+ Returns:
408
+ The aggregate ID as a UUID, or None if not found or invalid.
409
+ """
410
+ config = self._get_notification_config_if_enabled()
411
+ if config is None:
412
+ return None
413
+
414
+ aggregate_id_value = values.get(config.aggregate_id_column)
415
+ if aggregate_id_value is None:
416
+ return None
417
+
418
+ if isinstance(aggregate_id_value, UUID):
419
+ return aggregate_id_value
420
+
421
+ try:
422
+ return UUID(str(aggregate_id_value))
423
+ except (ValueError, TypeError):
424
+ logger.warning(
425
+ "Invalid aggregate ID value: %s",
426
+ aggregate_id_value,
427
+ extra={
428
+ "projector_id": self.projector_id,
429
+ "value_type": type(aggregate_id_value).__name__,
430
+ },
431
+ )
432
+ return None
433
+
434
+ def _extract_version_from_values(
435
+ self,
436
+ values: dict[str, object],
437
+ ) -> int:
438
+ """Extract the version from projection values.
439
+
440
+ Args:
441
+ values: Column name to value mapping from value extraction.
442
+
443
+ Returns:
444
+ The version as an integer. Returns 0 if:
445
+ - Notifications are not enabled
446
+ - No version column is configured
447
+ - Version column value is missing or invalid
448
+ """
449
+ config = self._get_notification_config_if_enabled()
450
+ if config is None:
451
+ return 0
452
+
453
+ if config.version_column is None:
454
+ return 0
455
+
456
+ version_value = values.get(config.version_column)
457
+ if version_value is None:
458
+ return 0
459
+
460
+ if isinstance(version_value, int):
461
+ return version_value
462
+
463
+ try:
464
+ # Convert to string first to satisfy type checker
465
+ return int(str(version_value))
466
+ except (ValueError, TypeError):
467
+ return 0
468
+
469
+ async def _publish_transition_notification(
470
+ self,
471
+ event: ModelEventEnvelope[object],
472
+ from_state: str | None,
473
+ to_state: str,
474
+ projection_version: int,
475
+ aggregate_id: UUID,
476
+ correlation_id: UUID,
477
+ ) -> None:
478
+ """Publish a state transition notification after successful projection.
479
+
480
+ Creates a ModelStateTransitionNotification and publishes it via the
481
+ configured notification publisher. This method is best-effort - errors
482
+ are logged but not raised.
483
+
484
+ Args:
485
+ event: The event envelope that triggered the projection.
486
+ from_state: The previous state value, or None for new entities.
487
+ to_state: The new state value after projection.
488
+ projection_version: The projection version for ordering.
489
+ aggregate_id: The aggregate instance ID.
490
+ correlation_id: Correlation ID for distributed tracing.
491
+ """
492
+ publisher = self._get_notification_publisher_if_enabled()
493
+ if publisher is None:
494
+ return
495
+
496
+ # Handle new entities (no previous state)
497
+ # from_state is required in the notification model; use FROM_STATE_INITIAL sentinel
498
+ # for new entities to clearly distinguish from empty string state values.
499
+ # See constants_notification.py for full documentation on this sentinel.
500
+ effective_from_state = (
501
+ from_state if from_state is not None else FROM_STATE_INITIAL
502
+ )
503
+
504
+ # Create notification
505
+ notification = ModelStateTransitionNotification(
506
+ aggregate_type=self.aggregate_type,
507
+ aggregate_id=aggregate_id,
508
+ from_state=effective_from_state,
509
+ to_state=to_state,
510
+ projection_version=projection_version,
511
+ correlation_id=correlation_id,
512
+ causation_id=event.envelope_id,
513
+ timestamp=datetime.now(UTC),
514
+ )
515
+
516
+ try:
517
+ await publisher.publish(notification)
518
+
519
+ logger.debug(
520
+ "Published transition notification",
521
+ extra={
522
+ "projector_id": self.projector_id,
523
+ "aggregate_type": self.aggregate_type,
524
+ "aggregate_id": str(aggregate_id),
525
+ "from_state": effective_from_state,
526
+ "to_state": to_state,
527
+ "projection_version": projection_version,
528
+ "correlation_id": str(correlation_id),
529
+ },
530
+ )
531
+
532
+ except (InfraConnectionError, InfraTimeoutError, InfraUnavailableError) as e:
533
+ # Log but don't fail - notifications are best-effort (expected failures)
534
+ logger.warning(
535
+ "Failed to publish transition notification: %s",
536
+ str(e),
537
+ extra={
538
+ "projector_id": self.projector_id,
539
+ "aggregate_type": self.aggregate_type,
540
+ "aggregate_id": str(aggregate_id),
541
+ "from_state": effective_from_state,
542
+ "to_state": to_state,
543
+ "correlation_id": str(correlation_id),
544
+ "error_type": type(e).__name__,
545
+ },
546
+ )
547
+ except Exception as e:
548
+ # Log unexpected errors at ERROR level for visibility
549
+ logger.exception(
550
+ "Unexpected error publishing transition notification",
551
+ extra={
552
+ "projector_id": self.projector_id,
553
+ "aggregate_type": self.aggregate_type,
554
+ "aggregate_id": str(aggregate_id),
555
+ "from_state": effective_from_state,
556
+ "to_state": to_state,
557
+ "correlation_id": str(correlation_id),
558
+ "error_type": type(e).__name__,
559
+ },
560
+ )
561
+
562
+
563
+ __all__: list[str] = [
564
+ "MixinProjectorNotificationPublishing",
565
+ "ProtocolProjectorNotificationContext",
566
+ ]
@@ -513,8 +513,9 @@ class MixinProjectorSqlOperations:
513
513
  values = self._normalize_values(values)
514
514
 
515
515
  schema = self._contract.projection_schema
516
- # schema.primary_key is a str field in omnibase_core.ModelProjectorSchema
517
- pk_list = [schema.primary_key]
516
+ # Normalize primary_key to list (handles both str and list[str] from omnibase_core)
517
+ pk = schema.primary_key
518
+ pk_list: list[str] = pk if isinstance(pk, list) else [pk]
518
519
 
519
520
  # Determine conflict columns (single or composite)
520
521
  conflict_cols = conflict_columns if conflict_columns else pk_list
@@ -643,7 +644,10 @@ class MixinProjectorSqlOperations:
643
644
  False if no row was found matching the aggregate_id.
644
645
 
645
646
  Raises:
646
- ProtocolConfigurationError: If updates dict is empty.
647
+ ProtocolConfigurationError: If updates dict is empty, or if the
648
+ schema has a composite primary key. Composite PK schemas
649
+ cannot use this method because using only part of the key
650
+ could cause unintended multi-row updates.
647
651
 
648
652
  Note:
649
653
  This method does NOT check whether column names exist in the contract
@@ -651,11 +655,11 @@ class MixinProjectorSqlOperations:
651
655
  enables updating columns that may not be in the projection schema
652
656
  (e.g., internal tracking columns like updated_at).
653
657
 
654
- **Composite Primary Key Handling**: For schemas with composite primary
655
- keys, this method uses only the first column for the WHERE clause.
656
- This works when the first column is globally unique (e.g., UUID).
657
- For true composite key updates, build a custom UPDATE query or use
658
- ``_partial_upsert()`` with explicit ``conflict_columns``.
658
+ **Composite Primary Key Restriction**: Schemas with composite primary
659
+ keys (e.g., ``["entity_id", "domain"]``) are explicitly rejected to
660
+ prevent accidental multi-row updates. For composite key schemas, use
661
+ ``_partial_upsert()`` with explicit ``conflict_columns`` parameter,
662
+ or build a custom UPDATE query with a full WHERE clause.
659
663
 
660
664
  Example:
661
665
  >>> # Update heartbeat tracking fields
@@ -687,8 +691,25 @@ class MixinProjectorSqlOperations:
687
691
 
688
692
  schema = self._contract.projection_schema
689
693
  table_quoted = quote_identifier(schema.table)
690
- # schema.primary_key is a str field in omnibase_core.ModelProjectorSchema
691
- pk_quoted = quote_identifier(schema.primary_key)
694
+
695
+ # Validate primary key is single-column to prevent multi-row updates
696
+ pk = schema.primary_key
697
+ if isinstance(pk, list) and len(pk) > 1:
698
+ context = ModelInfraErrorContext(
699
+ transport_type=EnumInfraTransportType.RUNTIME,
700
+ operation="partial_update",
701
+ correlation_id=correlation_id,
702
+ )
703
+ raise ProtocolConfigurationError(
704
+ f"partial_update does not support composite primary keys. "
705
+ f"Schema '{schema.table}' has composite PK: {pk}. "
706
+ f"Use _partial_upsert() with explicit conflict_columns parameter instead, "
707
+ f"or build a custom UPDATE query with full WHERE clause.",
708
+ context=context,
709
+ )
710
+
711
+ pk_col = pk[0] if isinstance(pk, list) else pk
712
+ pk_quoted = quote_identifier(pk_col)
692
713
 
693
714
  # Build SET clause with parameterized placeholders
694
715
  # Column names are quoted for SQL safety; values use $1, $2, etc.