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
This commit is contained in:
tegwick 2026-10-13 23:33:42 +02:00
parent e8fb648f25
commit 9e4d5ba23c
10 changed files with 762 additions and 2 deletions

View file

@ -12,6 +12,7 @@ from hub_core.runtime.config import RuntimeSettings
from hub_core.runtime.compat import SQLCompatibilityStore, create_compatibility_router
from hub_core.runtime.models import HealthResponse, ReadinessResponse
from hub_core.runtime.ports import create_ports_router
from hub_core.runtime.legacy_topic_aliases import load_legacy_topic_aliases
from hub_core.runtime.repository_navigation import (
ProjectionRejected,
RepoProjectionClient,
@ -86,6 +87,14 @@ def create_app(
repository_navigation = RepositoryNavigationService(
client=resolved_repo_projection_client,
store=resolved_store,
topic_spine_mode=resolved_settings.rm_slice_topicspine,
# Loaded (and validated) only when the A5 slice is not off, so the
# default path is unchanged; a bad seed fails startup closed.
legacy_topic_aliases=(
load_legacy_topic_aliases()
if resolved_settings.rm_slice_topicspine != "off"
else None
),
)
workload_projection = WorkloadProjectionService(
client=workload_projection_client,

View file

@ -3,6 +3,11 @@ 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)
@ -39,6 +44,11 @@ class RuntimeSettings:
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:
@ -54,6 +64,8 @@ class RuntimeSettings:
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:
@ -100,6 +112,9 @@ class RuntimeSettings:
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]:
@ -117,7 +132,7 @@ class RuntimeSettings:
or bool(self.api_token)
or store_backend == "postgresql"
)
return {
checks = {
"environment": self.environment,
"configured_backend": self.backend,
"active_backend": store_backend,
@ -128,6 +143,9 @@ class RuntimeSettings:
"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)

View file

@ -0,0 +1,99 @@
{
"alias_set_id": "hub-core.legacy-topic-aliases",
"alias_set_version": "2026-10-13.1",
"target_contract": "helixforge.repository-navigation 1.0.0",
"source": {
"system": "state-hub",
"request": "GET /topics/?status=active",
"captured_at": "2026-10-13",
"evidence": "state-hub docs/a5-topic-spine-comparison-packet.md (STATE-WP-0098-T01), cases A5-C01/C02",
"note": "Checked-in seed. hub-core never fetches topics from State Hub at runtime. Topic UUIDs are stable identity; do not change them while repos carry topic_id."
},
"authority": false,
"facet_kind": "primary_domain",
"residual_owner": "STATE-WP-0079",
"expires_at": "2027-03-31T23:59:59Z",
"slug_normalisation": "Legacy slugs containing underscores are emitted with hyphens because the 1.0.0 legacyTopicAlias.legacy_topic_slug pattern forbids underscores; legacy_topic_slug keeps the State Hub spelling here.",
"aliases": [
{
"legacy_topic_id": "fe2aaa78-9c20-4feb-b3d2-4fe0529572a3",
"legacy_topic_slug": "canon",
"facet_value": "infotech"
},
{
"legacy_topic_id": "96fa8e80-9f74-40f2-84cd-644e9747b9ec",
"legacy_topic_slug": "citation_evidence_mvp",
"facet_value": "infotech"
},
{
"legacy_topic_id": "36c7421b-c537-4723-bf75-42a3ebc6a1dc",
"legacy_topic_slug": "coulomb-social",
"facet_value": "communication"
},
{
"legacy_topic_id": "cee7bedf-2b48-46ef-8601-006474f2ad7a",
"legacy_topic_slug": "custodian",
"facet_value": "infotech"
},
{
"legacy_topic_id": "64418556-3206-457a-ba29-6884b5b12cf3",
"legacy_topic_slug": "foerster-capabilities",
"facet_value": "agents"
},
{
"legacy_topic_id": "f39fa2a3-c491-414c-a91b-b4c5fcc6139c",
"legacy_topic_slug": "helix-forge",
"facet_value": "infotech"
},
{
"legacy_topic_id": "1f2e4d10-c967-4803-ae6c-7f4b4e806409",
"legacy_topic_slug": "inter_hub",
"facet_value": "infotech"
},
{
"legacy_topic_id": "5571d954-0d30-4950-980d-7bcaaad8e3e2",
"legacy_topic_slug": "markitect",
"facet_value": "communication"
},
{
"legacy_topic_id": "a6c6e745-bf54-4465-9340-1534a2be493e",
"legacy_topic_slug": "netkingdom",
"facet_value": "communication"
},
{
"legacy_topic_id": "e61b8d01-78ec-47af-a68b-b92afa6fb188",
"legacy_topic_slug": "nudge-space",
"facet_value": "infotech"
},
{
"legacy_topic_id": "084430ab-c630-48dc-9e1d-d07d1e8fce3c",
"legacy_topic_slug": "personhood",
"facet_value": "government"
},
{
"legacy_topic_id": "c1d199b6-55ee-4db6-b49e-257a9f0f15ac",
"legacy_topic_slug": "practice",
"facet_value": "agents"
},
{
"legacy_topic_id": "ca369340-a64e-442e-98f1-a4fa7dc74a38",
"legacy_topic_slug": "railiance",
"facet_value": "financials"
},
{
"legacy_topic_id": "595afc64-bd28-47bf-aafb-ba230b28371b",
"legacy_topic_slug": "stack",
"facet_value": "infotech"
},
{
"legacy_topic_id": "7d4d3e35-312f-4723-bde9-ddb43799109e",
"legacy_topic_slug": "vergabe_teilnahme",
"facet_value": "government"
},
{
"legacy_topic_id": "4c2e5315-2cb9-447c-9d16-a39bdb0aabd0",
"legacy_topic_slug": "whynot",
"facet_value": "consumer"
}
]
}

View file

@ -0,0 +1,182 @@
"""Checked-in, versioned legacy State Hub topic aliases (A5 topic spine).
Aliases are temporary read-only compatibility metadata defined by the
``helixforge.repository-navigation`` 1.0.0 contract (``legacyTopicAlias``).
They map one stable State Hub topic UUID/slug to one derived facet. They are
seeded from a file in this package, never fetched from State Hub at runtime,
and they cannot create or change repository classification.
Emission is gated by ``RM_SLICE_TOPICSPINE`` (see ``RuntimeSettings``).
"""
from __future__ import annotations
import json
from collections.abc import Mapping
from dataclasses import dataclass
from datetime import datetime, timezone
from importlib import resources
from pathlib import Path
from typing import Any
from jsonschema import Draft202012Validator, FormatChecker, ValidationError
from hub_core.contracts import repository_navigation_contract_root
DEFAULT_ALIAS_RESOURCE = "data/legacy_topic_aliases.json"
TOPIC_SPINE_MODES = frozenset({"off", "shadow", "on"})
class LegacyTopicAliasError(ValueError):
"""The checked-in alias seed is invalid and must not be served."""
@dataclass(frozen=True, slots=True)
class LegacyTopicAlias:
legacy_topic_id: str
legacy_topic_slug: str # State Hub spelling, may contain underscores
facet_kind: str
facet_value: str
expires_at: datetime
residual_owner: str
@property
def contract_slug(self) -> str:
return self.legacy_topic_slug.replace("_", "-")
def to_contract(self, *, now: datetime) -> dict[str, Any]:
return {
"legacy_topic_id": self.legacy_topic_id,
"legacy_topic_slug": self.contract_slug,
"facet_kind": self.facet_kind,
"facet_value": self.facet_value,
"status": "expired" if now >= self.expires_at else "active",
"expires_at": _format_time(self.expires_at),
"residual_owner": self.residual_owner,
}
@dataclass(frozen=True, slots=True)
class LegacyTopicAliasSet:
alias_set_id: str
alias_set_version: str
aliases: tuple[LegacyTopicAlias, ...]
def to_contract(self, *, now: datetime | None = None) -> list[dict[str, Any]]:
moment = now or datetime.now(timezone.utc)
return [alias.to_contract(now=moment) for alias in self.aliases]
def resolve(self, key: str) -> LegacyTopicAlias | None:
"""Look up an alias by legacy topic UUID or (legacy or contract) slug."""
for alias in self.aliases:
if key in {alias.legacy_topic_id, alias.legacy_topic_slug, alias.contract_slug}:
return alias
return None
def load_legacy_topic_aliases(path: str | Path | None = None) -> LegacyTopicAliasSet:
if path is None:
text = (
resources.files("hub_core.runtime")
.joinpath(DEFAULT_ALIAS_RESOURCE)
.read_text(encoding="utf-8")
)
else:
text = Path(path).read_text(encoding="utf-8")
return parse_legacy_topic_aliases(json.loads(text))
def parse_legacy_topic_aliases(document: Mapping[str, Any]) -> LegacyTopicAliasSet:
# Imported lazily to avoid a module cycle with repository_navigation.
from hub_core.runtime.repository_navigation import _valid_facet_value
try:
if document.get("authority") is not False:
raise LegacyTopicAliasError("alias seed must declare authority: false")
set_id = _required_str(document, "alias_set_id")
version = _required_str(document, "alias_set_version")
default_kind = document.get("facet_kind", "primary_domain")
default_owner = document.get("residual_owner")
default_expiry = document.get("expires_at")
entries = document.get("aliases")
if not isinstance(entries, list) or not entries:
raise LegacyTopicAliasError("alias seed must contain a non-empty aliases list")
validator = _alias_validator()
aliases: list[LegacyTopicAlias] = []
seen_ids: set[str] = set()
seen_slugs: set[str] = set()
for entry in entries:
alias = LegacyTopicAlias(
legacy_topic_id=str(entry["legacy_topic_id"]).lower(),
legacy_topic_slug=str(entry["legacy_topic_slug"]),
facet_kind=str(entry.get("facet_kind", default_kind)),
facet_value=str(entry["facet_value"]),
expires_at=_parse_time(entry.get("expires_at", default_expiry)),
residual_owner=str(entry.get("residual_owner", default_owner) or ""),
)
if alias.legacy_topic_id in seen_ids:
raise LegacyTopicAliasError(f"duplicate legacy_topic_id {alias.legacy_topic_id}")
if alias.contract_slug in seen_slugs:
raise LegacyTopicAliasError(f"duplicate legacy_topic_slug {alias.contract_slug}")
seen_ids.add(alias.legacy_topic_id)
seen_slugs.add(alias.contract_slug)
if alias.facet_kind not in {"primary_domain", "secondary_domain", "category",
"capability_tag", "business_stake", "business_mechanic"}:
raise LegacyTopicAliasError(f"unknown facet_kind {alias.facet_kind}")
if not _valid_facet_value(alias.facet_kind, alias.facet_value):
raise LegacyTopicAliasError(
f"alias {alias.legacy_topic_slug} targets invalid facet value {alias.facet_value}"
)
validator.validate(alias.to_contract(now=datetime.now(timezone.utc)))
aliases.append(alias)
except ValidationError as exc:
raise LegacyTopicAliasError(f"alias violates legacyTopicAlias: {exc.message}") from exc
except (KeyError, TypeError) as exc:
raise LegacyTopicAliasError(f"malformed alias seed: {exc}") from exc
return LegacyTopicAliasSet(
alias_set_id=set_id,
alias_set_version=version,
aliases=tuple(sorted(aliases, key=lambda item: item.legacy_topic_id)),
)
def normalize_topic_spine_mode(raw: str | None) -> str:
value = (raw or "off").strip().lower()
if value not in TOPIC_SPINE_MODES:
raise ValueError("RM_SLICE_TOPICSPINE must be one of off, shadow, on")
return value
def _alias_validator() -> Draft202012Validator:
schema = json.loads(
repository_navigation_contract_root()
.joinpath("schemas", "repository-navigation-projection.schema.json")
.read_text(encoding="utf-8")
)
alias_schema = {
"$schema": schema.get("$schema"),
"$defs": schema["$defs"],
"$ref": "#/$defs/legacyTopicAlias",
}
return Draft202012Validator(alias_schema, format_checker=FormatChecker())
def _required_str(document: Mapping[str, Any], key: str) -> str:
value = document.get(key)
if not isinstance(value, str) or not value:
raise LegacyTopicAliasError(f"alias seed requires {key}")
return value
def _parse_time(value: Any) -> datetime:
if not isinstance(value, str):
raise LegacyTopicAliasError("every alias requires expires_at")
parsed = datetime.fromisoformat(value.replace("Z", "+00:00"))
if parsed.tzinfo is None:
raise LegacyTopicAliasError("expires_at must carry a timezone")
return parsed.astimezone(timezone.utc)
def _format_time(value: datetime) -> str:
return value.astimezone(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")

View file

@ -18,6 +18,7 @@ from hub_core.contracts import (
REPOSITORY_NAVIGATION_CONTRACT_VERSION,
repository_navigation_contract_root,
)
from hub_core.runtime.legacy_topic_aliases import LegacyTopicAliasSet
class RepoProjectionClient(Protocol):
@ -94,9 +95,20 @@ class RepositoryNavigationService:
*,
client: RepoProjectionClient | None,
store: RepositoryNavigationStore,
topic_spine_mode: str = "off",
legacy_topic_aliases: LegacyTopicAliasSet | None = None,
) -> None:
if topic_spine_mode not in {"off", "shadow", "on"}:
raise ValueError("RM_SLICE_TOPICSPINE must be one of off, shadow, on")
if topic_spine_mode != "off" and legacy_topic_aliases is None:
raise ValueError("RM_SLICE_TOPICSPINE requires a legacy topic alias set")
self.client = client
self.store = store
self.topic_spine_mode = topic_spine_mode
# Aliases are never served while the slice is off, even if supplied.
self.legacy_topic_aliases = (
legacy_topic_aliases if topic_spine_mode != "off" else None
)
self._validator = _input_validator()
self._lock = asyncio.Lock()
self._last_error: str | None = None
@ -185,6 +197,10 @@ class RepositoryNavigationService:
else None
)
result["total_repository_count"] = len(matches)
if self.legacy_topic_aliases is not None:
result["legacy_topic_aliases"] = self.legacy_topic_aliases.to_contract(
now=_now()
)
return result
async def _fetch_transfer(self) -> list[dict[str, Any]]:

View file

@ -2,13 +2,16 @@ from __future__ import annotations
from typing import Any
from fastapi import APIRouter, Depends, HTTPException, Query, Request
from fastapi import APIRouter, Depends, HTTPException, Query, Request, Response
from hub_core.runtime.repository_navigation import (
ProjectionCursorMismatch,
RepositoryNavigationService,
)
TOPIC_SPINE_HEADER = "X-RM-Slice-Topicspine"
TOPIC_ALIAS_SET_HEADER = "X-Legacy-Topic-Alias-Set"
def get_repository_navigation_service(request: Request) -> RepositoryNavigationService:
return request.app.state.repository_navigation
@ -24,6 +27,7 @@ def create_repository_navigation_router() -> APIRouter:
openapi_extra={"x-port-id": "port.projection.query", "x-direction": "out"},
)
async def query_repositories(
response: Response,
primary_domain: list[str] | None = Query(default=None),
secondary_domain: list[str] | None = Query(default=None),
category: list[str] | None = Query(default=None),
@ -38,6 +42,7 @@ def create_repository_navigation_router() -> APIRouter:
) -> dict[str, Any]:
return await _query(
service,
response,
filters={
"primary_domain": primary_domain,
"secondary_domain": secondary_domain,
@ -57,6 +62,7 @@ def create_repository_navigation_router() -> APIRouter:
openapi_extra={"x-port-id": "port.projection.query", "x-direction": "out"},
)
async def query_facet(
response: Response,
facet_kind: str,
facet_value: str,
cursor: str | None = None,
@ -67,6 +73,7 @@ def create_repository_navigation_router() -> APIRouter:
) -> dict[str, Any]:
return await _query(
service,
response,
filters={facet_kind: [facet_value]},
cursor=cursor,
limit=limit,
@ -77,6 +84,7 @@ def create_repository_navigation_router() -> APIRouter:
async def _query(
service: RepositoryNavigationService,
response: Response,
*,
filters: dict[str, list[str] | None],
cursor: str | None,
@ -93,4 +101,10 @@ async def _query(
status_code=503,
detail="no accepted repository navigation projection is available",
)
if service.topic_spine_mode != "off":
response.headers[TOPIC_SPINE_HEADER] = service.topic_spine_mode
if service.legacy_topic_aliases is not None:
response.headers[TOPIC_ALIAS_SET_HEADER] = (
service.legacy_topic_aliases.alias_set_version
)
return result