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

@ -77,3 +77,32 @@ entry maps one State Hub topic UUID/slug to one derived facet and must name an
classification. Expired aliases return `410` until removed; there are no write classification. Expired aliases return `410` until removed; there are no write
operations in the navigation OpenAPI surface. STATE-WP-0079 owns alias expiry operations in the navigation OpenAPI surface. STATE-WP-0079 owns alias expiry
and the consumer route switch. and the consumer route switch.
### Runtime alias source (HUB-WP-0006-T06)
The runtime serves aliases from one checked-in, versioned seed:
`hub_core/runtime/data/legacy_topic_aliases.json` (`alias_set_version`
`2026-10-13.1`). It holds the 16 active State Hub topics captured on
2026-10-13 by `GET /topics/?status=active` (State Hub packet
STATE-WP-0098-T01). Each alias maps `legacy_topic_id` to
`facet_kind = primary_domain` and `facet_value = topic.domain_slug`, with
`residual_owner = STATE-WP-0079` and `expires_at = 2027-03-31T23:59:59Z`.
hub-core never fetches topics from State Hub at runtime. Topic UUIDs are
identity and must not change while repositories carry `topic_id`. To renew
or retire aliases, edit the seed and bump `alias_set_version`.
The 1.0.0 `legacy_topic_slug` pattern forbids underscores. Three legacy
slugs (`inter_hub`, `vergabe_teilnahme`, `citation_evidence_mvp`) are
emitted with hyphens; the seed keeps the State Hub spelling. Comparisons
must normalise `_` to `-`. Emitting the exact spelling needs a contract
patch that widens the pattern.
`status` is computed per response: `expired` from `expires_at` onward.
Aliases are emitted unfiltered on every navigation response, so a facet
query still carries all of them. There is no per-alias route yet, so the
`410` behaviour above applies only once such a route exists.
Aliases are served only when `RM_SLICE_TOPICSPINE` is `shadow` or `on`
(see `docs/runtime.md`). With the default `off`, `legacy_topic_aliases`
stays `[]`.

View file

@ -66,6 +66,29 @@ canonical content hash. MCP exposes the same surface through
`query_repository_navigation` and `get_repository_navigation_facet`. No `query_repository_navigation` and `get_repository_navigation_facet`. No
classification-write endpoint exists. classification-write endpoint exists.
### `RM_SLICE_TOPICSPINE` (A5 topic-spine rollback switch)
`RM_SLICE_TOPICSPINE` controls whether hub-core serves the A5 topic-spine
reads. These are the legacy topic aliases on the navigation responses. It
takes one of three values:
| Value | Behaviour |
| --- | --- |
| `off` (default) | Unchanged output: `legacy_topic_aliases` is `[]`, there are no extra headers, and the alias seed is not loaded. |
| `shadow` | Serves the checked-in aliases so the A5 comparison can run. Responses carry `X-RM-Slice-Topicspine: shadow` and `X-Legacy-Topic-Alias-Set: <version>`. Callers have not switched. |
| `on` | Same payload with header value `on`. hub-core is now the switched A5 read source. |
Any other value fails startup. In `shadow` and `on`, a bad alias seed also
fails startup. When the flag is not `off`, `/readyz` reports
`rm_slice_topicspine`.
**Rollback:** set `RM_SLICE_TOPICSPINE=off` and restart. State Hub's
`GET /topics/` and `GET /topics/{id}` remain the fallback throughout.
Confirm the rollback with packet case A5-C10. The flag does not re-point
callers; each caller owner switches its own base URL. Do not move the flag
from `off` in any environment until HUB-WP-0006-T06's switch window is
approved.
Workload projection uses a distinct injected `WorkloadProjectionClient` and Workload projection uses a distinct injected `WorkloadProjectionClient` and
the authoritative `helixforge.workload-reference/v1` identity pair. Migration the authoritative `helixforge.workload-reference/v1` identity pair. Migration
`0004_workload_projection` stores only normalized transport rows and source `0004_workload_projection` stores only normalized transport rows and source

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.compat import SQLCompatibilityStore, create_compatibility_router
from hub_core.runtime.models import HealthResponse, ReadinessResponse from hub_core.runtime.models import HealthResponse, ReadinessResponse
from hub_core.runtime.ports import create_ports_router 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 ( from hub_core.runtime.repository_navigation import (
ProjectionRejected, ProjectionRejected,
RepoProjectionClient, RepoProjectionClient,
@ -86,6 +87,14 @@ def create_app(
repository_navigation = RepositoryNavigationService( repository_navigation = RepositoryNavigationService(
client=resolved_repo_projection_client, client=resolved_repo_projection_client,
store=resolved_store, 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( workload_projection = WorkloadProjectionService(
client=workload_projection_client, client=workload_projection_client,

View file

@ -3,6 +3,11 @@ from __future__ import annotations
import os import os
from dataclasses import dataclass 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: def _env_bool(name: str, default: bool) -> bool:
raw = os.getenv(name) raw = os.getenv(name)
@ -39,6 +44,11 @@ class RuntimeSettings:
statehub_inbox_reads: bool = False statehub_inbox_reads: bool = False
statehub_inbox_agent: str = "state-hub" statehub_inbox_agent: str = "state-hub"
access_mode: str = "auto" 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 @property
def enforce_access(self) -> bool: def enforce_access(self) -> bool:
@ -54,6 +64,8 @@ class RuntimeSettings:
if self.statehub_inbox_reads and (self.backend != "postgresql" or if self.statehub_inbox_reads and (self.backend != "postgresql" or
(not self.enforce_access and not self.api_token)): (not self.enforce_access and not self.api_token)):
raise ValueError("State Hub inbox reads require PostgreSQL and operator 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: if self.repo_manager_timeout_seconds <= 0:
raise ValueError("Repo Manager timeout must be positive") raise ValueError("Repo Manager timeout must be positive")
if self.repo_projection_refresh_seconds < 0: 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_reads=_env_bool("HUB_CORE_STATEHUB_INBOX_READS", False),
statehub_inbox_agent=os.getenv("HUB_CORE_STATEHUB_INBOX_AGENT", "state-hub"), statehub_inbox_agent=os.getenv("HUB_CORE_STATEHUB_INBOX_AGENT", "state-hub"),
access_mode=os.getenv("HUB_CORE_ACCESS_MODE", "auto"), 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]: def readiness_checks(self, store_backend: str) -> dict[str, str]:
@ -117,7 +132,7 @@ class RuntimeSettings:
or bool(self.api_token) or bool(self.api_token)
or store_backend == "postgresql" or store_backend == "postgresql"
) )
return { checks = {
"environment": self.environment, "environment": self.environment,
"configured_backend": self.backend, "configured_backend": self.backend,
"active_backend": store_backend, "active_backend": store_backend,
@ -128,6 +143,9 @@ class RuntimeSettings:
"v2_write_groups": ",".join(sorted(self.v2_write_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", "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: def is_ready(self, store_backend: str) -> bool:
checks = self.readiness_checks(store_backend) 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_VERSION,
repository_navigation_contract_root, repository_navigation_contract_root,
) )
from hub_core.runtime.legacy_topic_aliases import LegacyTopicAliasSet
class RepoProjectionClient(Protocol): class RepoProjectionClient(Protocol):
@ -94,9 +95,20 @@ class RepositoryNavigationService:
*, *,
client: RepoProjectionClient | None, client: RepoProjectionClient | None,
store: RepositoryNavigationStore, store: RepositoryNavigationStore,
topic_spine_mode: str = "off",
legacy_topic_aliases: LegacyTopicAliasSet | None = 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.client = client
self.store = store 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._validator = _input_validator()
self._lock = asyncio.Lock() self._lock = asyncio.Lock()
self._last_error: str | None = None self._last_error: str | None = None
@ -185,6 +197,10 @@ class RepositoryNavigationService:
else None else None
) )
result["total_repository_count"] = len(matches) 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 return result
async def _fetch_transfer(self) -> list[dict[str, Any]]: async def _fetch_transfer(self) -> list[dict[str, Any]]:

View file

@ -2,13 +2,16 @@ from __future__ import annotations
from typing import Any 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 ( from hub_core.runtime.repository_navigation import (
ProjectionCursorMismatch, ProjectionCursorMismatch,
RepositoryNavigationService, 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: def get_repository_navigation_service(request: Request) -> RepositoryNavigationService:
return request.app.state.repository_navigation 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"}, openapi_extra={"x-port-id": "port.projection.query", "x-direction": "out"},
) )
async def query_repositories( async def query_repositories(
response: Response,
primary_domain: list[str] | None = Query(default=None), primary_domain: list[str] | None = Query(default=None),
secondary_domain: list[str] | None = Query(default=None), secondary_domain: list[str] | None = Query(default=None),
category: 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]: ) -> dict[str, Any]:
return await _query( return await _query(
service, service,
response,
filters={ filters={
"primary_domain": primary_domain, "primary_domain": primary_domain,
"secondary_domain": secondary_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"}, openapi_extra={"x-port-id": "port.projection.query", "x-direction": "out"},
) )
async def query_facet( async def query_facet(
response: Response,
facet_kind: str, facet_kind: str,
facet_value: str, facet_value: str,
cursor: str | None = None, cursor: str | None = None,
@ -67,6 +73,7 @@ def create_repository_navigation_router() -> APIRouter:
) -> dict[str, Any]: ) -> dict[str, Any]:
return await _query( return await _query(
service, service,
response,
filters={facet_kind: [facet_value]}, filters={facet_kind: [facet_value]},
cursor=cursor, cursor=cursor,
limit=limit, limit=limit,
@ -77,6 +84,7 @@ def create_repository_navigation_router() -> APIRouter:
async def _query( async def _query(
service: RepositoryNavigationService, service: RepositoryNavigationService,
response: Response,
*, *,
filters: dict[str, list[str] | None], filters: dict[str, list[str] | None],
cursor: str | None, cursor: str | None,
@ -93,4 +101,10 @@ async def _query(
status_code=503, status_code=503,
detail="no accepted repository navigation projection is available", 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 return result

View file

@ -0,0 +1,103 @@
{
"captured_at": "2026-10-13",
"request": "GET /topics/?status=active",
"source": "State Hub primary (read-only GET; mirrors STATE-WP-0098-T01 capture)",
"topics": [
{
"id": "084430ab-c630-48dc-9e1d-d07d1e8fce3c",
"slug": "personhood",
"domain_slug": "government",
"status": "active"
},
{
"id": "1f2e4d10-c967-4803-ae6c-7f4b4e806409",
"slug": "inter_hub",
"domain_slug": "infotech",
"status": "active"
},
{
"id": "36c7421b-c537-4723-bf75-42a3ebc6a1dc",
"slug": "coulomb-social",
"domain_slug": "communication",
"status": "active"
},
{
"id": "4c2e5315-2cb9-447c-9d16-a39bdb0aabd0",
"slug": "whynot",
"domain_slug": "consumer",
"status": "active"
},
{
"id": "5571d954-0d30-4950-980d-7bcaaad8e3e2",
"slug": "markitect",
"domain_slug": "communication",
"status": "active"
},
{
"id": "595afc64-bd28-47bf-aafb-ba230b28371b",
"slug": "stack",
"domain_slug": "infotech",
"status": "active"
},
{
"id": "64418556-3206-457a-ba29-6884b5b12cf3",
"slug": "foerster-capabilities",
"domain_slug": "agents",
"status": "active"
},
{
"id": "7d4d3e35-312f-4723-bde9-ddb43799109e",
"slug": "vergabe_teilnahme",
"domain_slug": "government",
"status": "active"
},
{
"id": "96fa8e80-9f74-40f2-84cd-644e9747b9ec",
"slug": "citation_evidence_mvp",
"domain_slug": "infotech",
"status": "active"
},
{
"id": "a6c6e745-bf54-4465-9340-1534a2be493e",
"slug": "netkingdom",
"domain_slug": "communication",
"status": "active"
},
{
"id": "c1d199b6-55ee-4db6-b49e-257a9f0f15ac",
"slug": "practice",
"domain_slug": "agents",
"status": "active"
},
{
"id": "ca369340-a64e-442e-98f1-a4fa7dc74a38",
"slug": "railiance",
"domain_slug": "financials",
"status": "active"
},
{
"id": "cee7bedf-2b48-46ef-8601-006474f2ad7a",
"slug": "custodian",
"domain_slug": "infotech",
"status": "active"
},
{
"id": "e61b8d01-78ec-47af-a68b-b92afa6fb188",
"slug": "nudge-space",
"domain_slug": "infotech",
"status": "active"
},
{
"id": "f39fa2a3-c491-414c-a91b-b4c5fcc6139c",
"slug": "helix-forge",
"domain_slug": "infotech",
"status": "active"
},
{
"id": "fe2aaa78-9c20-4feb-b3d2-4fe0529572a3",
"slug": "canon",
"domain_slug": "infotech",
"status": "active"
}
]
}

View file

@ -0,0 +1,267 @@
"""Legacy topic alias seed and the RM_SLICE_TOPICSPINE A5 rollback switch.
The ``test_a5_*`` cases run the State Hub A5 comparison packet
(STATE-WP-0098-T01, cases A5-C01..C10) locally against checked-in fixtures:
the legacy side is the 2026-10-13 ``GET /topics/?status=active`` capture and
the candidate side is the in-memory runtime fed with the contract fixture page.
"""
from __future__ import annotations
import json
from copy import deepcopy
from datetime import datetime, timezone
from pathlib import Path
from typing import Any
import pytest
from fastapi.testclient import TestClient
from jsonschema import Draft202012Validator, FormatChecker
from hub_core.contracts import repository_navigation_contract_root
from hub_core.runtime.app import create_app
from hub_core.runtime.config import RuntimeSettings
from hub_core.runtime.legacy_topic_aliases import (
LegacyTopicAliasError,
load_legacy_topic_aliases,
parse_legacy_topic_aliases,
)
from hub_core.runtime.repository_navigation import RepositoryNavigationService
from hub_core.runtime.store import InMemoryPortStore
LEGACY_CAPTURE = Path(__file__).parent / "fixtures" / "a5-legacy-topics-2026-10-13.json"
SEED = Path(__file__).parents[1] / "hub_core" / "runtime" / "data" / "legacy_topic_aliases.json"
REPOSITORIES = "/ports/projections/repository-navigation/repositories"
def legacy_topics() -> list[dict[str, Any]]:
return json.loads(LEGACY_CAPTURE.read_text(encoding="utf-8"))["topics"]
def seed_document() -> dict[str, Any]:
return json.loads(SEED.read_text(encoding="utf-8"))
def fixture_page() -> dict[str, Any]:
return json.loads(
repository_navigation_contract_root()
.joinpath("fixtures", "repository-classification-page.json")
.read_text(encoding="utf-8")
)
def output_validator() -> Draft202012Validator:
schema = json.loads(
repository_navigation_contract_root()
.joinpath("schemas", "repository-navigation-projection.schema.json")
.read_text(encoding="utf-8")
)
return Draft202012Validator(schema, format_checker=FormatChecker())
class PageClient:
def __init__(self, page: dict[str, Any]) -> None:
self.page = page
async def fetch_classification_page(self, cursor: str | None) -> dict[str, Any]:
return deepcopy(self.page)
def runtime(mode: str | None) -> TestClient:
kwargs = {} if mode is None else {"rm_slice_topicspine": mode}
settings = RuntimeSettings(
environment="test", backend="memory", allow_ephemeral=True, **kwargs
)
return TestClient(
create_app(
settings=settings,
port_store=InMemoryPortStore(),
repo_projection_client=PageClient(fixture_page()),
)
)
def as_legacy_rows(aliases: list[dict[str, Any]]) -> set[tuple[str, str, str]]:
return {
(alias["legacy_topic_id"], alias["legacy_topic_slug"], alias["facet_value"])
for alias in aliases
}
def expected_rows() -> set[tuple[str, str, str]]:
# Normalisation documented in the seed: 1.0.0 slugs forbid underscores.
return {
(topic["id"], topic["slug"].replace("_", "-"), topic["domain_slug"])
for topic in legacy_topics()
}
# --- seed ------------------------------------------------------------------
def test_seed_is_versioned_non_authoritative_and_expiring() -> None:
document = seed_document()
alias_set = load_legacy_topic_aliases()
assert document["authority"] is False
assert alias_set.alias_set_version == document["alias_set_version"]
assert len(alias_set.aliases) == 16
assert all(alias.facet_kind == "primary_domain" for alias in alias_set.aliases)
assert all(alias.residual_owner == "STATE-WP-0079" for alias in alias_set.aliases)
assert all(
alias.expires_at == datetime(2027, 3, 31, 23, 59, 59, tzinfo=timezone.utc)
for alias in alias_set.aliases
)
def test_seed_keeps_legacy_topic_ids_and_spelling_stable() -> None:
alias_set = load_legacy_topic_aliases()
by_id = {alias.legacy_topic_id: alias for alias in alias_set.aliases}
for topic in legacy_topics():
alias = by_id[topic["id"]]
assert alias.legacy_topic_slug == topic["slug"]
assert alias.facet_value == topic["domain_slug"]
assert alias_set.resolve("inter_hub") is alias_set.resolve("inter-hub")
assert alias_set.resolve("cee7bedf-2b48-46ef-8601-006474f2ad7a").facet_value == "infotech"
assert alias_set.resolve("no-such-topic") is None
def test_alias_status_turns_expired_at_expires_at() -> None:
alias_set = load_legacy_topic_aliases()
before = alias_set.to_contract(now=datetime(2027, 3, 31, 23, 59, 58, tzinfo=timezone.utc))
after = alias_set.to_contract(now=datetime(2027, 3, 31, 23, 59, 59, tzinfo=timezone.utc))
assert {alias["status"] for alias in before} == {"active"}
assert {alias["status"] for alias in after} == {"expired"}
@pytest.mark.parametrize(
("mutate", "message"),
[
(lambda doc: doc.update(authority=True), "authority"),
(lambda doc: doc.pop("expires_at"), "expires_at"),
(lambda doc: doc.update(residual_owner=""), "legacyTopicAlias"),
(lambda doc: doc["aliases"].append(dict(doc["aliases"][0])), "duplicate"),
(lambda doc: doc["aliases"][0].update(facet_value="no-such-domain"), "invalid facet"),
(lambda doc: doc["aliases"][0].update(legacy_topic_id="not-a-uuid"), "legacyTopicAlias"),
(lambda doc: doc.update(aliases=[]), "non-empty"),
],
)
def test_invalid_seed_is_rejected(mutate, message) -> None:
document = seed_document()
mutate(document)
with pytest.raises(LegacyTopicAliasError, match=message):
parse_legacy_topic_aliases(document)
# --- RM_SLICE_TOPICSPINE ---------------------------------------------------
def test_flag_defaults_off_and_reads_the_documented_env_name(monkeypatch) -> None:
monkeypatch.delenv("RM_SLICE_TOPICSPINE", raising=False)
assert RuntimeSettings().rm_slice_topicspine == "off"
assert RuntimeSettings.from_env().rm_slice_topicspine == "off"
monkeypatch.setenv("RM_SLICE_TOPICSPINE", " Shadow ")
assert RuntimeSettings.from_env().rm_slice_topicspine == "shadow"
monkeypatch.setenv("RM_SLICE_TOPICSPINE", "true")
with pytest.raises(ValueError, match="RM_SLICE_TOPICSPINE"):
RuntimeSettings.from_env()
with pytest.raises(ValueError, match="RM_SLICE_TOPICSPINE"):
RuntimeSettings(rm_slice_topicspine="maybe")
def test_service_never_serves_aliases_while_off() -> None:
service = RepositoryNavigationService(
client=None,
store=InMemoryPortStore(),
legacy_topic_aliases=load_legacy_topic_aliases(),
)
assert service.topic_spine_mode == "off"
assert service.legacy_topic_aliases is None
with pytest.raises(ValueError, match="alias set"):
RepositoryNavigationService(client=None, store=InMemoryPortStore(), topic_spine_mode="on")
def test_flag_off_leaves_navigation_output_unchanged() -> None:
with runtime(None) as default, runtime("off") as off:
responses = [client.get(REPOSITORIES) for client in (default, off)]
ready = off.get("/readyz").json()["checks"]
for response in responses:
assert response.status_code == 200
assert response.json()["legacy_topic_aliases"] == []
assert "x-rm-slice-topicspine" not in response.headers
assert "x-legacy-topic-alias-set" not in response.headers
strip = lambda body: {k: v for k, v in body.items() if k not in {"rebuilt_at", "source_checked_at"}}
assert strip(responses[0].json()) == strip(responses[1].json())
assert "rm_slice_topicspine" not in ready
@pytest.mark.parametrize("mode", ["shadow", "on"])
def test_flag_shadow_or_on_serves_contract_valid_aliases(mode: str) -> None:
with runtime(mode) as client:
listing = client.get(REPOSITORIES)
facet = client.get(
"/ports/projections/repository-navigation/facets/primary_domain/infotech"
)
ready = client.get("/readyz").json()["checks"]
for response in (listing, facet):
assert response.status_code == 200
output_validator().validate(response.json())
assert response.headers["x-rm-slice-topicspine"] == mode
assert response.headers["x-legacy-topic-alias-set"] == "2026-10-13.1"
assert len(response.json()["legacy_topic_aliases"]) == 16
# Classification rows are untouched by the alias metadata.
assert [row["slug"] for row in listing.json()["repositories"]] == ["hub-core", "repo-manager"]
assert ready["rm_slice_topicspine"] == mode
# --- A5 comparison cases run locally against fixtures -----------------------
def test_a5_c01_topic_list_maps_to_aliases() -> None:
with runtime("shadow") as client:
aliases = client.get(REPOSITORIES, params={"limit": 500}).json()["legacy_topic_aliases"]
assert as_legacy_rows(aliases) == expected_rows()
assert {alias["facet_kind"] for alias in aliases} == {"primary_domain"}
assert all(alias["expires_at"] and alias["residual_owner"] for alias in aliases)
def test_a5_c02_and_c08_each_topic_id_resolves_to_one_alias() -> None:
with runtime("shadow") as client:
aliases = client.get(REPOSITORIES, params={"limit": 500}).json()["legacy_topic_aliases"]
for topic in legacy_topics():
matches = [alias for alias in aliases if alias["legacy_topic_id"] == topic["id"]]
assert len(matches) == 1
assert matches[0]["facet_value"] == topic["domain_slug"]
custodian = next(a for a in aliases if a["legacy_topic_id"] == "cee7bedf-2b48-46ef-8601-006474f2ad7a")
assert (custodian["legacy_topic_slug"], custodian["facet_value"]) == ("custodian", "infotech")
def test_a5_c03_unknown_facet_value_is_rejected_not_empty() -> None:
# Packet expected 200 + zero repositories (unverified); the runtime
# validates the controlled domain vocabulary and answers 400 instead.
with runtime("shadow") as client:
unknown = client.get(
"/ports/projections/repository-navigation/facets/primary_domain/no-such-domain"
)
empty = client.get(
"/ports/projections/repository-navigation/facets/primary_domain/space"
)
assert unknown.status_code == 400
assert empty.status_code == 200
assert empty.json()["repositories"] == []
def test_a5_c10_rollback_to_off_withdraws_aliases() -> None:
with runtime("on") as switched:
assert len(switched.get(REPOSITORIES).json()["legacy_topic_aliases"]) == 16
with runtime("off") as rolled_back:
response = rolled_back.get(REPOSITORIES)
assert response.json()["legacy_topic_aliases"] == []
assert "x-rm-slice-topicspine" not in response.headers