Skip to content
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@ python = "^3.12"
# omnibase-core = { git = "https://github.com/org/omnibase_core.git", rev = "SHA" }
# Remember to switch back to PyPI versions before merging to main.
#
# omnibase-core v0.10.2: Adds TOPIC_SUFFIX_CONTRACT_* constants
# omnibase-core v0.10.2: Adds TOPIC_SUFFIX_CONTRACT_* constants and request_response schema (OMN-1760)
omnibase-core = "^0.10.2"
# omnibase-spi v0.6.4: Version bump
omnibase-spi = "^0.6.4"
Expand Down
36 changes: 36 additions & 0 deletions src/omnibase_infra/services/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
ModelTimeoutEmissionResult: Result model for timeout emission processing
ModelTimeoutQueryResult: Result model for timeout queries
ServiceCapabilityQuery: Query nodes by capability, not by name
ServiceContractPublisher: Publish contracts to Kafka for dynamic discovery
ServiceNodeSelector: Select nodes from candidates using various strategies
ServiceSnapshot: Generic snapshot service for point-in-time state capture
ServiceTimeoutEmitter: Emitter for timeout events with markers
Expand All @@ -24,6 +25,25 @@
"""

from omnibase_infra.enums import EnumSelectionStrategy

# Contract publisher service (OMN-1752)
from omnibase_infra.services.contract_publisher import (
ContractPublisherError,
ContractPublishingInfraError,
ContractSourceNotConfiguredError,
ModelContractError,
ModelContractPublisherConfig,
ModelDiscoveredContract,
ModelInfraError,
ModelPublishResult,
ModelPublishStats,
NoContractsFoundError,
ProtocolContractPublisherSource,
ServiceContractPublisher,
SourceContractComposite,
SourceContractFilesystem,
SourceContractPackage,
)
from omnibase_infra.services.corpus_capture import CorpusCapture
from omnibase_infra.services.service_capability_query import ServiceCapabilityQuery
from omnibase_infra.services.service_node_selector import (
Expand Down Expand Up @@ -86,4 +106,20 @@
"SessionEventConsumer",
"SessionSnapshotStore",
"SessionStoreNotInitializedError",
# Contract publisher service (OMN-1752)
"ContractPublisherError",
"ContractPublishingInfraError",
"ContractSourceNotConfiguredError",
"ModelContractError",
"ModelContractPublisherConfig",
"ModelDiscoveredContract",
"ModelInfraError",
"ModelPublishResult",
"ModelPublishStats",
"NoContractsFoundError",
"ProtocolContractPublisherSource",
"ServiceContractPublisher",
"SourceContractComposite",
"SourceContractFilesystem",
"SourceContractPackage",
]
95 changes: 95 additions & 0 deletions src/omnibase_infra/services/contract_publisher/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
# SPDX-License-Identifier: MIT
# Copyright (c) 2025 OmniNode Team
"""Contract Publisher Service Module.

Provides infrastructure for bulk contract discovery and publishing to Kafka.
This service discovers contracts from configured sources (filesystem, package)
and publishes them to the contract registration topic for dynamic discovery.

Moved from omniclaude as part of OMN-1752 (ARCH-002 compliance).

Design Principle:
Infra standardizes the publishing *engine*; apps provide *source configuration*.

Flow:
Source → Validate → Normalize → Publish → Report
- Sources provide origin (filesystem, package)
- Event bus provides distribution (broadcast plane)

Example:
>>> from omnibase_infra.services.contract_publisher import (
... ServiceContractPublisher,
... ModelContractPublisherConfig,
... )
>>> config = ModelContractPublisherConfig(
... mode="filesystem",
... filesystem_root=Path("/app/contracts/handlers"),
... )
>>> publisher = await ServiceContractPublisher.from_container(container, config)
>>> result = await publisher.publish_all()
>>> if result:
... print(f"Published {len(result.published)} contracts")
... else:
... print(f"No contracts published, {len(result.contract_errors)} errors")

.. versionadded:: 0.3.0
Created as part of OMN-1752 (ContractPublisher extraction).
"""

# Config
from omnibase_infra.services.contract_publisher.config import (
ModelContractPublisherConfig,
)

# Errors
from omnibase_infra.services.contract_publisher.errors import (
ContractPublisherError,
ContractPublishingInfraError,
ContractSourceNotConfiguredError,
NoContractsFoundError,
)

# Result models
from omnibase_infra.services.contract_publisher.models import (
ModelContractError,
ModelInfraError,
ModelPublishResult,
ModelPublishStats,
)

# Service
from omnibase_infra.services.contract_publisher.service import (
ServiceContractPublisher,
)

# Sources
from omnibase_infra.services.contract_publisher.sources import (
ModelDiscoveredContract,
ProtocolContractPublisherSource,
SourceContractComposite,
SourceContractFilesystem,
SourceContractPackage,
)

__all__ = [
# Service
"ServiceContractPublisher",
# Config
"ModelContractPublisherConfig",
# Result models
"ModelPublishResult",
"ModelPublishStats",
"ModelContractError",
"ModelInfraError",
# Errors
"ContractPublisherError",
"ContractSourceNotConfiguredError",
"ContractPublishingInfraError",
"NoContractsFoundError",
# Sources
"ProtocolContractPublisherSource",
"ModelDiscoveredContract",
"SourceContractFilesystem",
"SourceContractPackage",
"SourceContractComposite",
]
199 changes: 199 additions & 0 deletions src/omnibase_infra/services/contract_publisher/config.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,199 @@
# SPDX-License-Identifier: MIT
# Copyright (c) 2025 OmniNode Team
"""Contract Publisher Configuration Model.

This module defines the configuration model for contract publishing operations.
Configuration is validated at construction time using Pydantic model validators.

Configuration Options:
mode: Publishing source mode (filesystem, package, composite)
filesystem_root: Root directory for filesystem mode
package_module: Module name for package mode
fail_fast: Whether to raise on infrastructure errors
allow_zero_contracts: Whether to allow empty publish results
environment: Environment prefix for Kafka topics

Environment Resolution:
The environment is resolved with precedence:
1. config.environment (if provided)
2. ONEX_ENV environment variable
3. Default "dev"

Related:
- OMN-1752: Extract ContractPublisher to omnibase_infra
- ARCH-002: Runtime owns all Kafka plumbing

.. versionadded:: 0.3.0
Created as part of OMN-1752 (ContractPublisher extraction).
"""

from __future__ import annotations

import os
from pathlib import Path
from typing import Literal, Self

from pydantic import BaseModel, ConfigDict, Field, model_validator


class ModelContractPublisherConfig(BaseModel):
"""Configuration for contract publishing.

Defines the source mode and configuration for discovering and
publishing contracts to Kafka.

Source Modes:
filesystem: Discover contracts from a directory tree
package: Discover contracts from installed package resources
composite: Merge both sources with conflict detection

Configuration Rules (enforced by validator):
- filesystem mode requires filesystem_root
- package mode requires package_module
- composite mode requires at least one of filesystem_root or package_module

Attributes:
mode: Publishing source mode
filesystem_root: Root directory for filesystem discovery
package_module: Module name for package resource discovery
fail_fast: If True, raise immediately on infrastructure errors
allow_zero_contracts: If True, allow empty publish results
environment: Environment prefix for topics (defaults via resolve_environment)

Example:
>>> config = ModelContractPublisherConfig(
... mode="filesystem",
... filesystem_root=Path("/app/contracts/handlers"),
... fail_fast=True,
... allow_zero_contracts=False,
... )
>>> env = config.resolve_environment()
>>> print(f"Publishing to {env}.onex.evt.contract-registered.v1")

.. versionadded:: 0.3.0
"""

model_config = ConfigDict(frozen=True, extra="forbid")

mode: Literal["filesystem", "package", "composite"] = Field(
description="Publishing source mode"
)
filesystem_root: Path | None = Field(
default=None,
description="Root directory for filesystem discovery",
)
package_module: str | None = Field(
default=None,
description="Module name for package resource discovery (e.g., 'myapp.contracts')",
)
fail_fast: bool = Field(
default=True,
description="If True, raise immediately on infrastructure errors",
)
allow_zero_contracts: bool = Field(
default=False,
description="If True, allow empty publish results without raising",
)
environment: str | None = Field(
default=None,
description="Environment prefix for topics (resolved via resolve_environment)",
)

@model_validator(mode="after")
def validate_source_configured(self) -> Self:
"""Validate that required source fields are configured for the mode.

Rules:
- filesystem mode requires filesystem_root
- package mode requires package_module
- composite mode requires at least one source

Returns:
Self if validation passes

Raises:
ValueError: If required source configuration is missing
"""
match self.mode:
case "filesystem":
if not self.filesystem_root:
raise ValueError("filesystem mode requires filesystem_root")
case "package":
if not self.package_module:
raise ValueError("package mode requires package_module")
case "composite":
if not self.filesystem_root and not self.package_module:
raise ValueError(
"composite mode requires at least one source "
"(filesystem_root or package_module)"
)
Comment on lines +102 to +129

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

# First, check if the file exists and read it
cat -n src/omnibase_infra/services/contract_publisher/config.py | head -150

Repository: OmniNode-ai/omnibase_infra

Length of output: 6489


🏁 Script executed:

# Search for OnexError usage in the codebase to understand the pattern
rg "OnexError" --type py -A 3 | head -100

Repository: OmniNode-ai/omnibase_infra

Length of output: 8011


🏁 Script executed:

# Check imports in the config.py file
head -50 src/omnibase_infra/services/contract_publisher/config.py

Repository: OmniNode-ai/omnibase_infra

Length of output: 1724


🏁 Script executed:

# Search for config-related error classes or OnexError subclasses
rg "class.*Error.*OnexError" --type py

Repository: OmniNode-ai/omnibase_infra

Length of output: 543


🏁 Script executed:

# Check how config validators in similar files handle errors
rg "model_validator|ValidationError|OnexError" src/omnibase_infra --type py -B 2 -A 2 | head -80

Repository: OmniNode-ai/omnibase_infra

Length of output: 7127


🏁 Script executed:

# Check what's in omnibase_core.errors module to understand available error classes
rg "from omnibase_core.errors import" src/omnibase_infra --type py | head -20

Repository: OmniNode-ai/omnibase_infra

Length of output: 2237


🏁 Script executed:

# Check if there are other config files with validators and how they handle errors
rg "model_validator|validator" src/omnibase_infra/services --type py -l

Repository: OmniNode-ai/omnibase_infra

Length of output: 288


🏁 Script executed:

# Look at config.py more completely to see if there's resolve_environment and other validators
sed -n '130,200p' src/omnibase_infra/services/contract_publisher/config.py

Repository: OmniNode-ai/omnibase_infra

Length of output: 1975


🏁 Script executed:

# Check if there are any config-specific error classes in the errors module
ls -la src/omnibase_infra/errors/

Repository: OmniNode-ai/omnibase_infra

Length of output: 1079


🏁 Script executed:

# Check error_infra.py for config-related error classes
rg "class.*Error" src/omnibase_infra/errors/error_infra.py --type py | head -20

Repository: OmniNode-ai/omnibase_infra

Length of output: 561


🏁 Script executed:

# Also check the __init__.py in errors to see what's exported
head -100 src/omnibase_infra/errors/__init__.py

Repository: OmniNode-ai/omnibase_infra

Length of output: 4413


🏁 Script executed:

# Check if there are validators in other config files
rg "model_validator" src/omnibase_infra/services --type py -B 2 -A 10

Repository: OmniNode-ai/omnibase_infra

Length of output: 7909


🏁 Script executed:

# Check how emit_daemon/config.py uses OnexError with validators or config
head -100 src/omnibase_infra/runtime/emit_daemon/config.py

Repository: OmniNode-ai/omnibase_infra

Length of output: 3772


🏁 Script executed:

# Search for PydanticErrorMixin or ValidationError handling patterns
rg "ValidationError|PydanticError" src/omnibase_infra --type py | head -20

Repository: OmniNode-ai/omnibase_infra

Length of output: 2482


🏁 Script executed:

# Check if there's a configuration-specific error class
rg "ConfigurationError|Config.*Error" src/omnibase_infra/errors --type py

Repository: OmniNode-ai/omnibase_infra

Length of output: 1054


🏁 Script executed:

# Look at config_consumer.py to see ProtocolConfigurationError usage pattern
grep -A 5 "ProtocolConfigurationError" src/omnibase_infra/services/session/config_consumer.py

Repository: OmniNode-ai/omnibase_infra

Length of output: 482


🏁 Script executed:

# Check what errors are imported in config_consumer.py
head -40 src/omnibase_infra/services/session/config_consumer.py

Repository: OmniNode-ai/omnibase_infra

Length of output: 1291


🏁 Script executed:

# Check ProtocolConfigurationError definition to understand if it accepts error chaining
grep -A 15 "class ProtocolConfigurationError" src/omnibase_infra/errors/error_infra.py

Repository: OmniNode-ai/omnibase_infra

Length of output: 648


🏁 Script executed:

# See how other validators in the codebase handle error chaining
rg "from omnibase_infra.errors import|raise.*Error.*from" src/omnibase_infra/services --type py -A 2 | head -40

Repository: OmniNode-ai/omnibase_infra

Length of output: 3086


🏁 Script executed:

# Check if config_store.py also raises ValueError to see consistency
grep -A 10 "validate_pool_sizes" src/omnibase_infra/services/session/config_store.py

Repository: OmniNode-ai/omnibase_infra

Length of output: 411


🏁 Script executed:

# Check what happens in emit_daemon config validators
grep -A 10 "model_validator" src/omnibase_infra/runtime/emit_daemon/config.py | head -50

Repository: OmniNode-ai/omnibase_infra

Length of output: 926


🏁 Script executed:

# Get more details on the emit_daemon config validators
sed -n '200,350p' src/omnibase_infra/runtime/emit_daemon/config.py

Repository: OmniNode-ai/omnibase_infra

Length of output: 5073


🏁 Script executed:

# Also check service_timeout_emitter.py to see how ProtocolConfigurationError is raised in validators
grep -B 5 -A 10 "raise ProtocolConfigurationError" src/omnibase_infra/services/service_timeout_emitter.py | head -30

Repository: OmniNode-ai/omnibase_infra

Length of output: 1491


Replace ValueError with ProtocolConfigurationError and include error chaining.

Current code violates the mandatory error policy by raising ValueError. Use ProtocolConfigurationError (the config-specific subclass) with the required error chaining pattern.

Corrected pattern
+from omnibase_infra.errors import ProtocolConfigurationError
@@
             case "filesystem":
                 if not self.filesystem_root:
-                    raise ValueError("filesystem mode requires filesystem_root")
+                    err = ValueError("filesystem mode requires filesystem_root")
+                    raise ProtocolConfigurationError(str(err)) from err
             case "package":
                 if not self.package_module:
-                    raise ValueError("package mode requires package_module")
+                    err = ValueError("package mode requires package_module")
+                    raise ProtocolConfigurationError(str(err)) from err
             case "composite":
                 if not self.filesystem_root and not self.package_module:
-                    raise ValueError(
-                        "composite mode requires at least one source "
-                        "(filesystem_root or package_module)"
-                    )
+                    err = ValueError(
+                        "composite mode requires at least one source "
+                        "(filesystem_root or package_module)"
+                    )
+                    raise ProtocolConfigurationError(str(err)) from err

Per coding guidelines: Only raise OnexError or its subclasses with error chaining pattern raise ... from e.

🤖 Prompt for AI Agents
In `@src/omnibase_infra/services/contract_publisher/config.py` around lines 102 -
129, The validator validate_source_configured currently raises ValueError;
replace those raises with ProtocolConfigurationError and use explicit error
chaining (raise ProtocolConfigurationError("...") from ValueError("...")) so
each branch (filesystem, package, composite) raises ProtocolConfigurationError
with the same message and chains from a ValueError constructed with that
message; update the three raise sites in validate_source_configured accordingly
(and ensure ProtocolConfigurationError is imported/available).

return self

def resolve_environment(self) -> str:
"""Resolve environment with precedence: config > env var > 'dev'.

Resolution Order:
1. self.environment (if provided and non-empty after normalization)
2. ONEX_ENV environment variable (if set and non-empty after normalization)
3. Default "dev"

Normalization:
- Whitespace is stripped
- Trailing dots are removed (to prevent "dev..topic" issues)

Note:
Whitespace-only strings (e.g., " ") are treated as empty and
fall through to the next priority level.

Returns:
Resolved environment string, normalized

Example:
>>> config = ModelContractPublisherConfig(
... mode="filesystem",
... filesystem_root=Path("/app"),
... environment="prod",
... )
>>> config.resolve_environment()
'prod'

>>> # With no config.environment and ONEX_ENV=staging
>>> config2 = ModelContractPublisherConfig(
... mode="filesystem",
... filesystem_root=Path("/app"),
... )
>>> # Returns "staging" if ONEX_ENV is set, else "dev"
"""
# Priority 1: Explicit config (if non-empty after normalization)
if self.environment:
normalized = self._normalize_environment(self.environment)
if normalized:
return normalized

# Priority 2: Environment variable (if non-empty after normalization)
env_var = os.getenv("ONEX_ENV", "")
if env_var:
normalized = self._normalize_environment(env_var)
if normalized:
return normalized

# Priority 3: Default
return "dev"

@staticmethod
def _normalize_environment(value: str) -> str:
"""Normalize environment string.

Strips whitespace and removes trailing dots to prevent
topic formatting issues like "dev..topic".

Args:
value: Raw environment value

Returns:
Normalized environment string
"""
return value.strip().rstrip(".")


__all__ = ["ModelContractPublisherConfig"]
Loading