omnibase_infra 0.3.1__py3-none-any.whl → 0.3.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 (72) hide show
  1. omnibase_infra/__init__.py +1 -1
  2. omnibase_infra/enums/__init__.py +3 -0
  3. omnibase_infra/enums/enum_consumer_group_purpose.py +9 -0
  4. omnibase_infra/enums/enum_postgres_error_code.py +188 -0
  5. omnibase_infra/handlers/registration_storage/handler_registration_storage_postgres.py +29 -20
  6. omnibase_infra/mixins/__init__.py +14 -0
  7. omnibase_infra/mixins/mixin_postgres_error_response.py +314 -0
  8. omnibase_infra/mixins/mixin_postgres_op_executor.py +298 -0
  9. omnibase_infra/models/__init__.py +3 -0
  10. omnibase_infra/{nodes/effects/models → models}/model_backend_result.py +22 -6
  11. omnibase_infra/models/projection/__init__.py +11 -0
  12. omnibase_infra/models/projection/model_contract_projection.py +170 -0
  13. omnibase_infra/models/projection/model_topic_projection.py +148 -0
  14. omnibase_infra/nodes/contract_registry_reducer/__init__.py +5 -0
  15. omnibase_infra/nodes/contract_registry_reducer/contract_registration_event_router.py +689 -0
  16. omnibase_infra/nodes/effects/__init__.py +1 -1
  17. omnibase_infra/nodes/effects/models/__init__.py +6 -4
  18. omnibase_infra/nodes/effects/models/model_registry_response.py +1 -1
  19. omnibase_infra/nodes/effects/protocol_consul_client.py +1 -1
  20. omnibase_infra/nodes/effects/protocol_postgres_adapter.py +1 -1
  21. omnibase_infra/nodes/effects/registry_effect.py +1 -1
  22. omnibase_infra/nodes/node_contract_persistence_effect/__init__.py +101 -0
  23. omnibase_infra/nodes/node_contract_persistence_effect/contract.yaml +490 -0
  24. omnibase_infra/nodes/node_contract_persistence_effect/handlers/__init__.py +74 -0
  25. omnibase_infra/nodes/node_contract_persistence_effect/handlers/handler_postgres_cleanup_topics.py +217 -0
  26. omnibase_infra/nodes/node_contract_persistence_effect/handlers/handler_postgres_contract_upsert.py +242 -0
  27. omnibase_infra/nodes/node_contract_persistence_effect/handlers/handler_postgres_deactivate.py +194 -0
  28. omnibase_infra/nodes/node_contract_persistence_effect/handlers/handler_postgres_heartbeat.py +243 -0
  29. omnibase_infra/nodes/node_contract_persistence_effect/handlers/handler_postgres_mark_stale.py +208 -0
  30. omnibase_infra/nodes/node_contract_persistence_effect/handlers/handler_postgres_topic_update.py +298 -0
  31. omnibase_infra/nodes/node_contract_persistence_effect/models/__init__.py +15 -0
  32. omnibase_infra/nodes/node_contract_persistence_effect/models/model_persistence_result.py +52 -0
  33. omnibase_infra/nodes/node_contract_persistence_effect/node.py +114 -0
  34. omnibase_infra/nodes/node_contract_persistence_effect/registry/__init__.py +27 -0
  35. omnibase_infra/nodes/node_contract_persistence_effect/registry/registry_infra_contract_persistence_effect.py +220 -0
  36. omnibase_infra/nodes/node_registry_effect/models/__init__.py +2 -2
  37. omnibase_infra/projectors/__init__.py +6 -0
  38. omnibase_infra/projectors/projection_reader_contract.py +1301 -0
  39. omnibase_infra/runtime/__init__.py +5 -0
  40. omnibase_infra/runtime/contract_registration_event_router.py +500 -0
  41. omnibase_infra/runtime/db/__init__.py +4 -0
  42. omnibase_infra/runtime/db/models/__init__.py +15 -10
  43. omnibase_infra/runtime/db/models/model_db_operation.py +40 -0
  44. omnibase_infra/runtime/db/models/model_db_param.py +24 -0
  45. omnibase_infra/runtime/db/models/model_db_repository_contract.py +40 -0
  46. omnibase_infra/runtime/db/models/model_db_return.py +26 -0
  47. omnibase_infra/runtime/db/models/model_db_safety_policy.py +32 -0
  48. omnibase_infra/runtime/intent_execution_router.py +430 -0
  49. omnibase_infra/runtime/models/__init__.py +6 -0
  50. omnibase_infra/runtime/models/model_contract_registry_config.py +41 -0
  51. omnibase_infra/runtime/models/model_intent_execution_summary.py +79 -0
  52. omnibase_infra/runtime/models/model_runtime_config.py +8 -0
  53. omnibase_infra/runtime/protocols/__init__.py +16 -0
  54. omnibase_infra/runtime/protocols/protocol_intent_executor.py +107 -0
  55. omnibase_infra/runtime/request_response_wiring.py +785 -0
  56. omnibase_infra/runtime/service_kernel.py +295 -8
  57. omnibase_infra/services/registry_api/models/__init__.py +25 -0
  58. omnibase_infra/services/registry_api/models/model_contract_ref.py +44 -0
  59. omnibase_infra/services/registry_api/models/model_contract_view.py +81 -0
  60. omnibase_infra/services/registry_api/models/model_response_contracts.py +50 -0
  61. omnibase_infra/services/registry_api/models/model_response_topics.py +50 -0
  62. omnibase_infra/services/registry_api/models/model_topic_summary.py +57 -0
  63. omnibase_infra/services/registry_api/models/model_topic_view.py +63 -0
  64. omnibase_infra/services/registry_api/routes.py +205 -6
  65. omnibase_infra/services/registry_api/service.py +528 -1
  66. omnibase_infra/validation/infra_validators.py +3 -1
  67. omnibase_infra/validation/validation_exemptions.yaml +54 -0
  68. {omnibase_infra-0.3.1.dist-info → omnibase_infra-0.3.2.dist-info}/METADATA +3 -3
  69. {omnibase_infra-0.3.1.dist-info → omnibase_infra-0.3.2.dist-info}/RECORD +72 -34
  70. {omnibase_infra-0.3.1.dist-info → omnibase_infra-0.3.2.dist-info}/WHEEL +0 -0
  71. {omnibase_infra-0.3.1.dist-info → omnibase_infra-0.3.2.dist-info}/entry_points.txt +0 -0
  72. {omnibase_infra-0.3.1.dist-info → omnibase_infra-0.3.2.dist-info}/licenses/LICENSE +0 -0
@@ -35,12 +35,16 @@ from omnibase_infra.nodes.node_service_discovery_effect.models.enum_health_statu
35
35
  )
36
36
  from omnibase_infra.services.registry_api.models import (
37
37
  ModelCapabilityWidgetMapping,
38
+ ModelContractRef,
39
+ ModelContractView,
38
40
  ModelPaginationInfo,
39
41
  ModelRegistryDiscoveryResponse,
40
42
  ModelRegistryHealthResponse,
41
43
  ModelRegistryInstanceView,
42
44
  ModelRegistryNodeView,
43
45
  ModelRegistrySummary,
46
+ ModelTopicSummary,
47
+ ModelTopicView,
44
48
  ModelWarning,
45
49
  ModelWidgetDefaults,
46
50
  ModelWidgetMapping,
@@ -49,7 +53,10 @@ from omnibase_infra.services.registry_api.models import (
49
53
  if TYPE_CHECKING:
50
54
  from omnibase_infra.handlers.service_discovery import HandlerServiceDiscoveryConsul
51
55
  from omnibase_infra.models.projection import ModelRegistrationProjection
52
- from omnibase_infra.projectors import ProjectionReaderRegistration
56
+ from omnibase_infra.projectors import (
57
+ ProjectionReaderContract,
58
+ ProjectionReaderRegistration,
59
+ )
53
60
 
54
61
  logger = logging.getLogger(__name__)
55
62
 
@@ -112,6 +119,7 @@ class ServiceRegistryDiscovery:
112
119
  container: ModelONEXContainer,
113
120
  projection_reader: ProjectionReaderRegistration | None = None,
114
121
  consul_handler: HandlerServiceDiscoveryConsul | None = None,
122
+ contract_reader: ProjectionReaderContract | None = None,
115
123
  widget_mapping_path: Path | None = None,
116
124
  ) -> None:
117
125
  """Initialize the registry discovery service.
@@ -123,6 +131,8 @@ class ServiceRegistryDiscovery:
123
131
  If not provided, node queries will return empty results with warnings.
124
132
  consul_handler: Optional Consul handler for live instances.
125
133
  If not provided, instance queries will return empty results with warnings.
134
+ contract_reader: Optional projection reader for contract registry.
135
+ If not provided, contract/topic queries will return empty results with warnings.
126
136
  widget_mapping_path: Path to widget mapping YAML file.
127
137
  Defaults to configs/widget_mapping.yaml relative to package.
128
138
  """
@@ -142,6 +152,9 @@ class ServiceRegistryDiscovery:
142
152
  # via the consul_handler parameter instead.
143
153
  self._consul_handler = consul_handler
144
154
 
155
+ # Contract reader for contract registry queries
156
+ self._contract_reader = contract_reader
157
+
145
158
  self._widget_mapping_path = widget_mapping_path or DEFAULT_WIDGET_MAPPING_PATH
146
159
  self._widget_mapping_cache: ModelWidgetMapping | None = None
147
160
  self._widget_mapping_mtime: float | None = None
@@ -151,6 +164,7 @@ class ServiceRegistryDiscovery:
151
164
  extra={
152
165
  "has_projection_reader": self._projection_reader is not None,
153
166
  "has_consul_handler": self._consul_handler is not None,
167
+ "has_contract_reader": self._contract_reader is not None,
154
168
  "widget_mapping_path": str(self._widget_mapping_path),
155
169
  },
156
170
  )
@@ -170,6 +184,11 @@ class ServiceRegistryDiscovery:
170
184
  """Get the Consul handler for lifecycle management."""
171
185
  return self._consul_handler
172
186
 
187
+ @property
188
+ def has_contract_reader(self) -> bool:
189
+ """Check if contract reader is configured."""
190
+ return self._contract_reader is not None
191
+
173
192
  def invalidate_widget_mapping_cache(self) -> None:
174
193
  """Clear widget mapping cache, forcing reload on next access.
175
194
 
@@ -833,5 +852,513 @@ class ServiceRegistryDiscovery:
833
852
  version="1.0.0",
834
853
  )
835
854
 
855
+ # ============================================================
856
+ # Contract Registry Methods
857
+ # ============================================================
858
+
859
+ async def list_contracts(
860
+ self,
861
+ limit: int = 100,
862
+ offset: int = 0,
863
+ active_only: bool = True,
864
+ node_name: str | None = None,
865
+ correlation_id: UUID | None = None,
866
+ ) -> tuple[list[ModelContractView], ModelPaginationInfo, list[ModelWarning]]:
867
+ """List contracts from projection reader.
868
+
869
+ Retrieves registered contracts with optional filtering by node name
870
+ and active status. Supports pagination.
871
+
872
+ Args:
873
+ limit: Maximum number of contracts to return (1-1000).
874
+ offset: Number of contracts to skip for pagination.
875
+ active_only: If True, return only active contracts.
876
+ node_name: Optional filter by node name.
877
+ correlation_id: Optional correlation ID for tracing.
878
+
879
+ Returns:
880
+ Tuple of (contracts, pagination_info, warnings).
881
+ """
882
+ correlation_id = correlation_id or uuid4()
883
+ warnings: list[ModelWarning] = []
884
+ contracts: list[ModelContractView] = []
885
+ total = 0
886
+
887
+ if self._contract_reader is None:
888
+ warnings.append(
889
+ ModelWarning(
890
+ source="postgres",
891
+ message="Contract reader not configured",
892
+ code="NO_CONTRACT_READER",
893
+ timestamp=datetime.now(UTC),
894
+ )
895
+ )
896
+ else:
897
+ try:
898
+ # Fetch contracts based on filters
899
+ if node_name:
900
+ # Filter by node name
901
+ projections = (
902
+ await self._contract_reader.list_contracts_by_node_name(
903
+ node_name=node_name,
904
+ include_inactive=not active_only,
905
+ correlation_id=correlation_id,
906
+ )
907
+ )
908
+ elif active_only:
909
+ # Active contracts only
910
+ projections = await self._contract_reader.list_active_contracts(
911
+ limit=limit + offset + 1, # Fetch extra to detect has_more
912
+ offset=0,
913
+ correlation_id=correlation_id,
914
+ )
915
+ else:
916
+ # All contracts (active + inactive)
917
+ # Note: The reader doesn't have a single method for all contracts,
918
+ # so we query active and then could extend for inactive if needed.
919
+ # For now, we use list_active_contracts as a fallback.
920
+ projections = await self._contract_reader.list_active_contracts(
921
+ limit=limit + offset + 1,
922
+ offset=0,
923
+ correlation_id=correlation_id,
924
+ )
925
+
926
+ # Calculate total and apply pagination
927
+ total = len(projections)
928
+
929
+ # Apply pagination in-memory for node_name filter
930
+ # (the reader's list_by_node_name doesn't support offset/limit)
931
+ if node_name:
932
+ projections_slice = projections[offset : offset + limit]
933
+ else:
934
+ # Already paginated by the reader
935
+ projections_slice = projections[offset : offset + limit]
936
+ total = max(total, offset + len(projections_slice))
937
+
938
+ # Batch fetch topics for all contracts to avoid N+1 query pattern
939
+ # This uses a single query instead of O(N) queries
940
+ contract_ids = [proj.contract_id for proj in projections_slice]
941
+ topics_by_contract: dict[str, list] = {}
942
+
943
+ if contract_ids:
944
+ try:
945
+ topics_by_contract = (
946
+ await self._contract_reader.get_topics_for_contracts(
947
+ contract_ids=contract_ids,
948
+ correlation_id=correlation_id,
949
+ )
950
+ )
951
+ except Exception as e:
952
+ # Log but continue - partial success with empty topics
953
+ logger.warning(
954
+ "Failed to batch fetch topics for contracts",
955
+ extra={
956
+ "contract_count": len(contract_ids),
957
+ "error": str(e),
958
+ "correlation_id": str(correlation_id),
959
+ },
960
+ )
961
+
962
+ # Convert to view models
963
+ for proj in projections_slice:
964
+ # Get topics for this contract from batch result
965
+ topics_published: list[str] = []
966
+ topics_subscribed: list[str] = []
967
+
968
+ contract_topics = topics_by_contract.get(proj.contract_id, [])
969
+ for topic in contract_topics:
970
+ if topic.direction == "publish":
971
+ topics_published.append(topic.topic_suffix)
972
+ elif topic.direction == "subscribe":
973
+ topics_subscribed.append(topic.topic_suffix)
974
+
975
+ contracts.append(
976
+ ModelContractView(
977
+ contract_id=proj.contract_id,
978
+ node_name=proj.node_name,
979
+ version=f"{proj.version_major}.{proj.version_minor}.{proj.version_patch}",
980
+ contract_hash=proj.contract_hash,
981
+ is_active=proj.is_active,
982
+ registered_at=proj.registered_at,
983
+ last_seen_at=proj.last_seen_at,
984
+ deregistered_at=proj.deregistered_at,
985
+ topics_published=topics_published,
986
+ topics_subscribed=topics_subscribed,
987
+ )
988
+ )
989
+
990
+ except Exception as e:
991
+ logger.exception(
992
+ "Failed to query contracts",
993
+ extra={"correlation_id": str(correlation_id)},
994
+ )
995
+ warnings.append(
996
+ ModelWarning(
997
+ source="postgres",
998
+ message=f"Failed to query contracts: {type(e).__name__}",
999
+ code="CONTRACT_QUERY_FAILED",
1000
+ timestamp=datetime.now(UTC),
1001
+ )
1002
+ )
1003
+
1004
+ pagination = ModelPaginationInfo(
1005
+ total=total,
1006
+ limit=limit,
1007
+ offset=offset,
1008
+ has_more=offset + len(contracts) < total,
1009
+ )
1010
+
1011
+ return contracts, pagination, warnings
1012
+
1013
+ async def get_contract(
1014
+ self,
1015
+ contract_id: str,
1016
+ correlation_id: UUID | None = None,
1017
+ ) -> tuple[ModelContractView | None, list[ModelWarning]]:
1018
+ """Get contract detail with topic references.
1019
+
1020
+ Retrieves a single contract by ID along with its published and
1021
+ subscribed topics.
1022
+
1023
+ Args:
1024
+ contract_id: Contract ID (e.g., "my-node:1.0.0")
1025
+ correlation_id: Optional correlation ID for tracing.
1026
+
1027
+ Returns:
1028
+ Tuple of (contract or None, warnings).
1029
+ """
1030
+ correlation_id = correlation_id or uuid4()
1031
+ warnings: list[ModelWarning] = []
1032
+
1033
+ if self._contract_reader is None:
1034
+ warnings.append(
1035
+ ModelWarning(
1036
+ source="postgres",
1037
+ message="Contract reader not configured",
1038
+ code="NO_CONTRACT_READER",
1039
+ timestamp=datetime.now(UTC),
1040
+ )
1041
+ )
1042
+ return None, warnings
1043
+
1044
+ try:
1045
+ proj = await self._contract_reader.get_contract_by_id(
1046
+ contract_id=contract_id,
1047
+ correlation_id=correlation_id,
1048
+ )
1049
+
1050
+ if proj is None:
1051
+ return None, warnings
1052
+
1053
+ # Get topics for this contract
1054
+ topics_published: list[str] = []
1055
+ topics_subscribed: list[str] = []
1056
+
1057
+ try:
1058
+ topics = await self._contract_reader.get_topics_by_contract(
1059
+ contract_id=contract_id,
1060
+ correlation_id=correlation_id,
1061
+ )
1062
+ for topic in topics:
1063
+ if topic.direction == "publish":
1064
+ topics_published.append(topic.topic_suffix)
1065
+ elif topic.direction == "subscribe":
1066
+ topics_subscribed.append(topic.topic_suffix)
1067
+ except Exception as e:
1068
+ logger.warning(
1069
+ "Failed to get topics for contract",
1070
+ extra={
1071
+ "contract_id": contract_id,
1072
+ "error": str(e),
1073
+ "correlation_id": str(correlation_id),
1074
+ },
1075
+ )
1076
+ warnings.append(
1077
+ ModelWarning(
1078
+ source="postgres",
1079
+ message=f"Failed to get topics: {type(e).__name__}",
1080
+ code="TOPIC_QUERY_FAILED",
1081
+ timestamp=datetime.now(UTC),
1082
+ )
1083
+ )
1084
+
1085
+ contract = ModelContractView(
1086
+ contract_id=proj.contract_id,
1087
+ node_name=proj.node_name,
1088
+ version=f"{proj.version_major}.{proj.version_minor}.{proj.version_patch}",
1089
+ contract_hash=proj.contract_hash,
1090
+ is_active=proj.is_active,
1091
+ registered_at=proj.registered_at,
1092
+ last_seen_at=proj.last_seen_at,
1093
+ deregistered_at=proj.deregistered_at,
1094
+ topics_published=topics_published,
1095
+ topics_subscribed=topics_subscribed,
1096
+ )
1097
+
1098
+ return contract, warnings
1099
+
1100
+ except Exception as e:
1101
+ logger.exception(
1102
+ "Failed to get contract",
1103
+ extra={
1104
+ "contract_id": contract_id,
1105
+ "correlation_id": str(correlation_id),
1106
+ },
1107
+ )
1108
+ warnings.append(
1109
+ ModelWarning(
1110
+ source="postgres",
1111
+ message=f"Failed to get contract: {type(e).__name__}",
1112
+ code="CONTRACT_QUERY_FAILED",
1113
+ timestamp=datetime.now(UTC),
1114
+ )
1115
+ )
1116
+ return None, warnings
1117
+
1118
+ async def list_topics(
1119
+ self,
1120
+ direction: str | None = None,
1121
+ limit: int = 100,
1122
+ offset: int = 0,
1123
+ correlation_id: UUID | None = None,
1124
+ ) -> tuple[list[ModelTopicSummary], ModelPaginationInfo, list[ModelWarning]]:
1125
+ """List topics from projection reader.
1126
+
1127
+ Retrieves topics with optional filtering by direction (publish/subscribe).
1128
+ Returns summary view with contract counts.
1129
+
1130
+ Args:
1131
+ direction: Optional filter by direction ('publish' or 'subscribe').
1132
+ limit: Maximum number of topics to return (1-1000).
1133
+ offset: Number of topics to skip for pagination.
1134
+ correlation_id: Optional correlation ID for tracing.
1135
+
1136
+ Returns:
1137
+ Tuple of (topics, pagination_info, warnings).
1138
+ """
1139
+ correlation_id = correlation_id or uuid4()
1140
+ warnings: list[ModelWarning] = []
1141
+ topics: list[ModelTopicSummary] = []
1142
+ total = 0
1143
+
1144
+ if self._contract_reader is None:
1145
+ warnings.append(
1146
+ ModelWarning(
1147
+ source="postgres",
1148
+ message="Contract reader not configured",
1149
+ code="NO_CONTRACT_READER",
1150
+ timestamp=datetime.now(UTC),
1151
+ )
1152
+ )
1153
+ else:
1154
+ try:
1155
+ projections = await self._contract_reader.list_topics(
1156
+ direction=direction,
1157
+ limit=limit + 1, # Fetch extra to detect has_more
1158
+ offset=offset,
1159
+ correlation_id=correlation_id,
1160
+ )
1161
+
1162
+ # Detect has_more from fetching limit+1
1163
+ has_more = len(projections) > limit
1164
+ projections_slice = projections[:limit]
1165
+
1166
+ # Get accurate total count for pagination
1167
+ try:
1168
+ total = await self._contract_reader.count_topics(
1169
+ direction=direction,
1170
+ correlation_id=correlation_id,
1171
+ )
1172
+ except Exception as count_error:
1173
+ # Fall back to estimate if count query fails
1174
+ logger.warning(
1175
+ "Failed to get accurate topic count, using estimate",
1176
+ extra={
1177
+ "correlation_id": str(correlation_id),
1178
+ "error": str(count_error),
1179
+ },
1180
+ )
1181
+ total = offset + len(projections)
1182
+
1183
+ for proj in projections_slice:
1184
+ topics.append(
1185
+ ModelTopicSummary(
1186
+ topic_suffix=proj.topic_suffix,
1187
+ direction=proj.direction,
1188
+ contract_count=len(proj.contract_ids),
1189
+ last_seen_at=proj.last_seen_at,
1190
+ is_active=proj.is_active,
1191
+ )
1192
+ )
1193
+
1194
+ except Exception as e:
1195
+ logger.exception(
1196
+ "Failed to query topics",
1197
+ extra={"correlation_id": str(correlation_id)},
1198
+ )
1199
+ warnings.append(
1200
+ ModelWarning(
1201
+ source="postgres",
1202
+ message=f"Failed to query topics: {type(e).__name__}",
1203
+ code="TOPIC_QUERY_FAILED",
1204
+ timestamp=datetime.now(UTC),
1205
+ )
1206
+ )
1207
+
1208
+ pagination = ModelPaginationInfo(
1209
+ total=total,
1210
+ limit=limit,
1211
+ offset=offset,
1212
+ has_more=offset + len(topics) < total,
1213
+ )
1214
+
1215
+ return topics, pagination, warnings
1216
+
1217
+ async def get_topic_detail(
1218
+ self,
1219
+ topic_suffix: str,
1220
+ correlation_id: UUID | None = None,
1221
+ ) -> tuple[ModelTopicView | None, list[ModelWarning]]:
1222
+ """Get topic detail with publisher/subscriber contracts.
1223
+
1224
+ Retrieves a topic by suffix, combining both publish and subscribe
1225
+ directions into a unified view with contract references.
1226
+
1227
+ Args:
1228
+ topic_suffix: Topic suffix (without environment prefix)
1229
+ correlation_id: Optional correlation ID for tracing.
1230
+
1231
+ Returns:
1232
+ Tuple of (topic or None, warnings).
1233
+ """
1234
+ correlation_id = correlation_id or uuid4()
1235
+ warnings: list[ModelWarning] = []
1236
+
1237
+ if self._contract_reader is None:
1238
+ warnings.append(
1239
+ ModelWarning(
1240
+ source="postgres",
1241
+ message="Contract reader not configured",
1242
+ code="NO_CONTRACT_READER",
1243
+ timestamp=datetime.now(UTC),
1244
+ )
1245
+ )
1246
+ return None, warnings
1247
+
1248
+ try:
1249
+ # Get both directions for this topic
1250
+ publish_topic = await self._contract_reader.get_topic(
1251
+ topic_suffix=topic_suffix,
1252
+ direction="publish",
1253
+ correlation_id=correlation_id,
1254
+ )
1255
+ subscribe_topic = await self._contract_reader.get_topic(
1256
+ topic_suffix=topic_suffix,
1257
+ direction="subscribe",
1258
+ correlation_id=correlation_id,
1259
+ )
1260
+
1261
+ # If neither direction exists, topic not found
1262
+ if publish_topic is None and subscribe_topic is None:
1263
+ return None, warnings
1264
+
1265
+ # Get contract details for publishers
1266
+ publishers: list[ModelContractRef] = []
1267
+ if publish_topic:
1268
+ for contract_id in publish_topic.contract_ids:
1269
+ try:
1270
+ contract = await self._contract_reader.get_contract_by_id(
1271
+ contract_id=contract_id,
1272
+ correlation_id=correlation_id,
1273
+ )
1274
+ if contract:
1275
+ publishers.append(
1276
+ ModelContractRef(
1277
+ contract_id=contract.contract_id,
1278
+ node_name=contract.node_name,
1279
+ version=f"{contract.version_major}.{contract.version_minor}.{contract.version_patch}",
1280
+ )
1281
+ )
1282
+ except Exception as e:
1283
+ logger.warning(
1284
+ "Failed to get publisher contract",
1285
+ extra={
1286
+ "contract_id": contract_id,
1287
+ "error": str(e),
1288
+ "correlation_id": str(correlation_id),
1289
+ },
1290
+ )
1291
+
1292
+ # Get contract details for subscribers
1293
+ subscribers: list[ModelContractRef] = []
1294
+ if subscribe_topic:
1295
+ for contract_id in subscribe_topic.contract_ids:
1296
+ try:
1297
+ contract = await self._contract_reader.get_contract_by_id(
1298
+ contract_id=contract_id,
1299
+ correlation_id=correlation_id,
1300
+ )
1301
+ if contract:
1302
+ subscribers.append(
1303
+ ModelContractRef(
1304
+ contract_id=contract.contract_id,
1305
+ node_name=contract.node_name,
1306
+ version=f"{contract.version_major}.{contract.version_minor}.{contract.version_patch}",
1307
+ )
1308
+ )
1309
+ except Exception as e:
1310
+ logger.warning(
1311
+ "Failed to get subscriber contract",
1312
+ extra={
1313
+ "contract_id": contract_id,
1314
+ "error": str(e),
1315
+ "correlation_id": str(correlation_id),
1316
+ },
1317
+ )
1318
+
1319
+ # Combine timestamps from both directions
1320
+ first_seen_at = min(
1321
+ t.first_seen_at
1322
+ for t in [publish_topic, subscribe_topic]
1323
+ if t is not None
1324
+ )
1325
+ last_seen_at = max(
1326
+ t.last_seen_at
1327
+ for t in [publish_topic, subscribe_topic]
1328
+ if t is not None
1329
+ )
1330
+ is_active = any(
1331
+ t.is_active for t in [publish_topic, subscribe_topic] if t is not None
1332
+ )
1333
+
1334
+ topic = ModelTopicView(
1335
+ topic_suffix=topic_suffix,
1336
+ publishers=publishers,
1337
+ subscribers=subscribers,
1338
+ first_seen_at=first_seen_at,
1339
+ last_seen_at=last_seen_at,
1340
+ is_active=is_active,
1341
+ )
1342
+
1343
+ return topic, warnings
1344
+
1345
+ except Exception as e:
1346
+ logger.exception(
1347
+ "Failed to get topic",
1348
+ extra={
1349
+ "topic_suffix": topic_suffix,
1350
+ "correlation_id": str(correlation_id),
1351
+ },
1352
+ )
1353
+ warnings.append(
1354
+ ModelWarning(
1355
+ source="postgres",
1356
+ message=f"Failed to get topic: {type(e).__name__}",
1357
+ code="TOPIC_QUERY_FAILED",
1358
+ timestamp=datetime.now(UTC),
1359
+ )
1360
+ )
1361
+ return None, warnings
1362
+
836
1363
 
837
1364
  __all__ = ["ServiceRegistryDiscovery"]
@@ -449,7 +449,9 @@ INFRA_NODES_PATH = "src/omnibase_infra/nodes/"
449
449
  # - 117 (2026-02-01): OMN-1783 PostgresRepositoryRuntime (+2 unions)
450
450
  # call() return type: list[dict] | dict | None
451
451
  # _execute_with_timeout() return type: list[dict] | dict | None
452
- INFRA_MAX_UNIONS = 117
452
+ # Note: OMN-1742 RequestResponseWiring uses UUID | None (optional, not counted)
453
+ # Note: OMN-1869 ContractRegistration added IntentPayloadType union (118th)
454
+ INFRA_MAX_UNIONS = 118
453
455
 
454
456
  # Maximum allowed architecture violations in infrastructure code.
455
457
  # Set to 0 (strict enforcement) to ensure one-model-per-file principle is always followed.
@@ -2030,6 +2030,60 @@ pattern_exemptions:
2030
2030
  documentation:
2031
2031
  - docs/plans/OMN-1743-migration-plan.md
2032
2032
  ticket: OMN-1743
2033
+ # ==========================================================================
2034
+ # Contract Registry Persistence Exemptions (OMN-1845)
2035
+ # ==========================================================================
2036
+ # contract_id is a derived natural key (node_name:major.minor.patch), not a UUID.
2037
+ # This design enables human-readable queries, version-based deduplication, and
2038
+ # matches the contract.yaml identification pattern used throughout ONEX.
2039
+ - file_pattern: 'models/projection/model_contract_projection\.py'
2040
+ violation_pattern: "Field 'contract_id' should use UUID"
2041
+ reason: >
2042
+ contract_id is a derived natural key (node_name:major.minor.patch), not a UUID.
2043
+
2044
+ documentation:
2045
+ - CLAUDE.md (Intent Model Architecture)
2046
+ ticket: OMN-1845
2047
+ - file_pattern: 'models/projection/model_contract_projection\.py'
2048
+ violation_pattern: "Field 'node_name' might reference an entity"
2049
+ reason: >
2050
+ node_name is the contract's semantic identifier, not a reference to a separate entity.
2051
+
2052
+ documentation:
2053
+ - CLAUDE.md (Contract-Driven)
2054
+ ticket: OMN-1845
2055
+ - file_pattern: 'services/registry_api/models/model_contract_view\.py'
2056
+ violation_pattern: "Field 'contract_id' should use UUID"
2057
+ reason: >
2058
+ contract_id is a derived natural key (node_name:major.minor.patch), not a UUID.
2059
+
2060
+ documentation:
2061
+ - CLAUDE.md (Intent Model Architecture)
2062
+ ticket: OMN-1845
2063
+ - file_pattern: 'services/registry_api/models/model_contract_view\.py'
2064
+ violation_pattern: "Field 'node_name' might reference an entity"
2065
+ reason: >
2066
+ node_name is the contract's semantic identifier, not a reference to a separate entity.
2067
+
2068
+ documentation:
2069
+ - CLAUDE.md (Contract-Driven)
2070
+ ticket: OMN-1845
2071
+ - file_pattern: 'services/registry_api/models/model_contract_ref\.py'
2072
+ violation_pattern: "Field 'contract_id' should use UUID"
2073
+ reason: >
2074
+ contract_id is a derived natural key (node_name:major.minor.patch), not a UUID.
2075
+
2076
+ documentation:
2077
+ - CLAUDE.md (Intent Model Architecture)
2078
+ ticket: OMN-1845
2079
+ - file_pattern: 'services/registry_api/models/model_contract_ref\.py'
2080
+ violation_pattern: "Field 'node_name' might reference an entity"
2081
+ reason: >
2082
+ node_name is the contract's semantic identifier, not a reference to a separate entity.
2083
+
2084
+ documentation:
2085
+ - CLAUDE.md (Contract-Driven)
2086
+ ticket: OMN-1845
2033
2087
  # Architecture validator exemptions
2034
2088
  # These handle one-model-per-file violations for domain-grouped protocols
2035
2089
  architecture_exemptions:
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: omnibase_infra
3
- Version: 0.3.1
3
+ Version: 0.3.2
4
4
  Summary: ONEX Infrastructure - Service integration and database infrastructure tools
5
5
  License: MIT
6
6
  License-File: LICENSE
@@ -28,7 +28,7 @@ Requires-Dist: jinja2 (>=3.1.6,<4.0.0)
28
28
  Requires-Dist: jsonschema (>=4.20.0,<5.0.0)
29
29
  Requires-Dist: mcp (>=1.25.0,<2.0.0)
30
30
  Requires-Dist: neo4j (>=5.15.0,<6.0.0)
31
- Requires-Dist: omnibase-core (>=0.12.0,<0.13.0)
31
+ Requires-Dist: omnibase-core (>=0.13.1,<0.14.0)
32
32
  Requires-Dist: omnibase-spi (>=0.6.4,<0.7.0)
33
33
  Requires-Dist: opentelemetry-api (>=1.27.0,<2.0.0)
34
34
  Requires-Dist: opentelemetry-exporter-otlp (>=1.27.0,<2.0.0)
@@ -43,7 +43,7 @@ Requires-Dist: prometheus-client (>=0.19.0,<0.20.0)
43
43
  Requires-Dist: psycopg2-binary (>=2.9.10,<3.0.0)
44
44
  Requires-Dist: pydantic (>=2.11.7,<3.0.0)
45
45
  Requires-Dist: pydantic-settings (>=2.2.1,<3.0.0)
46
- Requires-Dist: python-consul (>=1.1.0,<2.0.0)
46
+ Requires-Dist: python-consul2 (>=0.1.5,<0.2.0)
47
47
  Requires-Dist: pyyaml (>=6.0.2,<7.0.0)
48
48
  Requires-Dist: qdrant-client (>=1.12.0,<2.0.0)
49
49
  Requires-Dist: redis (>=6.0.0,<7.0.0)