hub-core/hub_core/runtime/config.py
tegwick 9e4d5ba23c HUB-WP-0006-T06: seed legacy topic aliases and add RM_SLICE_TOPICSPINE
- 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
2026-10-13 23:33:42 +02:00

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"
)