- hub_core/runtime/data/legacy_topic_aliases.json: checked-in, versioned (2026-10-13.1) seed of the 16 State Hub topics from the STATE-WP-0098-T01 capture -> primary_domain facet, residual owner STATE-WP-0079, expiry 2027-03-31T23:59:59Z. Never fetched live; topic UUIDs kept verbatim. - legacy_topic_aliases.py: loader validated against the 1.0.0 legacyTopicAlias schema; per-response active/expired status; underscore slugs emitted hyphenated (1.0.0 pattern forbids '_'). - RM_SLICE_TOPICSPINE setting (off|shadow|on, default off). off keeps the navigation output unchanged (aliases []); shadow/on serve the aliases and mark responses with X-RM-Slice-Topicspine. Documented as the A5 rollback switch in docs/runtime.md. - tests/test_legacy_topic_aliases.py: seed, flag and local A5 comparison cases (C01, C02/C08, C03, C10) against fixtures. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Assistant: claude-code Assistant-Model: opus Assistant-Process: 356991@bnt-lap001 Assistant-Session: 5d99ea69-ed55-4a1e-8d97-f4f312db247f
156 lines
6.9 KiB
Python
156 lines
6.9 KiB
Python
from __future__ import annotations
|
|
|
|
import os
|
|
from dataclasses import dataclass
|
|
|
|
from hub_core.runtime.legacy_topic_aliases import (
|
|
TOPIC_SPINE_MODES,
|
|
normalize_topic_spine_mode,
|
|
)
|
|
|
|
|
|
def _env_bool(name: str, default: bool) -> bool:
|
|
raw = os.getenv(name)
|
|
if raw is None:
|
|
return default
|
|
return raw.strip().lower() in {"1", "true", "yes", "on"}
|
|
|
|
|
|
def _env_set(name: str) -> frozenset[str]:
|
|
return frozenset(value.strip() for value in os.getenv(name, "").split(",") if value.strip())
|
|
|
|
|
|
@dataclass(frozen=True, slots=True)
|
|
class RuntimeSettings:
|
|
environment: str = "development"
|
|
backend: str = "memory"
|
|
allow_ephemeral: bool = True
|
|
api_host: str = "127.0.0.1"
|
|
api_port: int = 8010
|
|
api_base: str = "http://127.0.0.1:8010"
|
|
mcp_host: str = "127.0.0.1"
|
|
mcp_port: int = 8011
|
|
mcp_transport: str = "http"
|
|
database_url: str | None = None
|
|
api_token: str | None = None
|
|
repo_manager_base_url: str | None = None
|
|
repo_manager_api_token: str | None = None
|
|
repo_manager_timeout_seconds: float = 10.0
|
|
repo_projection_refresh_seconds: float = 300.0
|
|
v2_groups: frozenset[str] = frozenset()
|
|
v2_write_groups: frozenset[str] = frozenset()
|
|
legacy_write_groups: frozenset[str] = frozenset()
|
|
legacy_health: bool = False
|
|
statehub_inbox_reads: bool = False
|
|
statehub_inbox_agent: str = "state-hub"
|
|
access_mode: str = "auto"
|
|
# A5 topic-spine slice switch (HUB-WP-0006-T06). "off" (default) keeps the
|
|
# navigation output unchanged (legacy_topic_aliases == []); "shadow" emits
|
|
# the checked-in aliases for parity comparison only; "on" declares hub-core
|
|
# the switched A5 read source. Rollback = set back to "off".
|
|
rm_slice_topicspine: str = "off"
|
|
|
|
@property
|
|
def enforce_access(self) -> bool:
|
|
return self.access_mode == "enforce" or (
|
|
self.access_mode == "auto" and self.environment not in {"development", "test"}
|
|
)
|
|
|
|
def __post_init__(self) -> None:
|
|
if self.access_mode not in {"auto", "enforce", "development"}:
|
|
raise ValueError("unsupported access mode")
|
|
if self.access_mode == "development" and self.environment not in {"development", "test"}:
|
|
raise ValueError("development access is forbidden outside development/test")
|
|
if self.statehub_inbox_reads and (self.backend != "postgresql" or
|
|
(not self.enforce_access and not self.api_token)):
|
|
raise ValueError("State Hub inbox reads require PostgreSQL and operator token")
|
|
if self.rm_slice_topicspine not in TOPIC_SPINE_MODES:
|
|
raise ValueError("RM_SLICE_TOPICSPINE must be one of off, shadow, on")
|
|
if self.repo_manager_timeout_seconds <= 0:
|
|
raise ValueError("Repo Manager timeout must be positive")
|
|
if self.repo_projection_refresh_seconds < 0:
|
|
raise ValueError("repository projection refresh interval must not be negative")
|
|
overlap = self.v2_write_groups & self.legacy_write_groups
|
|
if overlap:
|
|
joined = ", ".join(sorted(overlap))
|
|
raise ValueError(f"hub-core and Core Hub write groups overlap: {joined}")
|
|
unknown = self.v2_write_groups - self.v2_groups
|
|
if unknown:
|
|
joined = ", ".join(sorted(unknown))
|
|
raise ValueError(f"hub-core write groups are not enabled: {joined}")
|
|
|
|
@classmethod
|
|
def from_env(cls) -> RuntimeSettings:
|
|
environment = os.getenv("HUB_CORE_ENV", "development")
|
|
default_ephemeral = environment in {"development", "test"}
|
|
api_host = os.getenv("HUB_CORE_API_HOST", "127.0.0.1")
|
|
api_port = int(os.getenv("HUB_CORE_API_PORT", "8010"))
|
|
return cls(
|
|
environment=environment,
|
|
backend=os.getenv("HUB_CORE_BACKEND", "memory"),
|
|
allow_ephemeral=_env_bool("HUB_CORE_ALLOW_EPHEMERAL", default_ephemeral),
|
|
api_host=api_host,
|
|
api_port=api_port,
|
|
api_base=os.getenv("HUB_CORE_API_BASE", f"http://127.0.0.1:{api_port}"),
|
|
mcp_host=os.getenv("HUB_CORE_MCP_HOST", "127.0.0.1"),
|
|
mcp_port=int(os.getenv("HUB_CORE_MCP_PORT", "8011")),
|
|
mcp_transport=os.getenv("HUB_CORE_MCP_TRANSPORT", "http"),
|
|
database_url=os.getenv("HUB_CORE_DATABASE_URL") or os.getenv("DATABASE_URL"),
|
|
api_token=os.getenv("HUB_CORE_API_TOKEN") or os.getenv("CORE_HUB_API_TOKEN"),
|
|
repo_manager_base_url=os.getenv("HUB_CORE_REPO_MANAGER_BASE_URL"),
|
|
repo_manager_api_token=os.getenv("HUB_CORE_REPO_MANAGER_API_TOKEN"),
|
|
repo_manager_timeout_seconds=float(
|
|
os.getenv("HUB_CORE_REPO_MANAGER_TIMEOUT_SECONDS", "10")
|
|
),
|
|
repo_projection_refresh_seconds=float(
|
|
os.getenv("HUB_CORE_REPO_PROJECTION_REFRESH_SECONDS", "300")
|
|
),
|
|
v2_groups=_env_set("HUB_CORE_V2_GROUPS"),
|
|
v2_write_groups=_env_set("HUB_CORE_V2_WRITE_GROUPS"),
|
|
legacy_write_groups=_env_set("CORE_HUB_V2_WRITE_GROUPS"),
|
|
legacy_health=_env_bool("HUB_CORE_LEGACY_HEALTH", False),
|
|
statehub_inbox_reads=_env_bool("HUB_CORE_STATEHUB_INBOX_READS", False),
|
|
statehub_inbox_agent=os.getenv("HUB_CORE_STATEHUB_INBOX_AGENT", "state-hub"),
|
|
access_mode=os.getenv("HUB_CORE_ACCESS_MODE", "auto"),
|
|
rm_slice_topicspine=normalize_topic_spine_mode(
|
|
os.getenv("RM_SLICE_TOPICSPINE")
|
|
),
|
|
)
|
|
|
|
def readiness_checks(self, store_backend: str) -> dict[str, str]:
|
|
ephemeral_allowed = store_backend != "memory" or self.allow_ephemeral
|
|
protected_groups = self.v2_groups & {
|
|
"registry",
|
|
"credentials",
|
|
"interaction",
|
|
"deferred",
|
|
"operator",
|
|
}
|
|
authorization_ready = (
|
|
self.enforce_access
|
|
or not protected_groups
|
|
or bool(self.api_token)
|
|
or store_backend == "postgresql"
|
|
)
|
|
checks = {
|
|
"environment": self.environment,
|
|
"configured_backend": self.backend,
|
|
"active_backend": store_backend,
|
|
"ephemeral_backend": "allowed" if ephemeral_allowed else "not_allowed",
|
|
"authorization": "ok" if authorization_ready else "unavailable",
|
|
"contract": "helixforge.hub-extension/0.1.0",
|
|
"v2_groups": ",".join(sorted(self.v2_groups)) or "none",
|
|
"v2_write_groups": ",".join(sorted(self.v2_write_groups)) or "none",
|
|
"legacy_write_groups": ",".join(sorted(self.legacy_write_groups)) or "none",
|
|
}
|
|
if self.rm_slice_topicspine != "off":
|
|
checks["rm_slice_topicspine"] = self.rm_slice_topicspine
|
|
return checks
|
|
|
|
def is_ready(self, store_backend: str) -> bool:
|
|
checks = self.readiness_checks(store_backend)
|
|
return (
|
|
self.backend == store_backend
|
|
and (store_backend != "memory" or self.allow_ephemeral)
|
|
and checks["authorization"] == "ok"
|
|
)
|