diff --git a/docs/repository-navigation-contract.md b/docs/repository-navigation-contract.md index d049f00..28e5e35 100644 --- a/docs/repository-navigation-contract.md +++ b/docs/repository-navigation-contract.md @@ -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 operations in the navigation OpenAPI surface. STATE-WP-0079 owns alias expiry 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 `[]`. + diff --git a/docs/runtime.md b/docs/runtime.md index 931257a..e473518 100644 --- a/docs/runtime.md +++ b/docs/runtime.md @@ -66,6 +66,29 @@ canonical content hash. MCP exposes the same surface through `query_repository_navigation` and `get_repository_navigation_facet`. No 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: `. 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 the authoritative `helixforge.workload-reference/v1` identity pair. Migration `0004_workload_projection` stores only normalized transport rows and source diff --git a/hub_core/runtime/app.py b/hub_core/runtime/app.py index b241d50..915cd44 100644 --- a/hub_core/runtime/app.py +++ b/hub_core/runtime/app.py @@ -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, diff --git a/hub_core/runtime/config.py b/hub_core/runtime/config.py index e3981f2..4b0520c 100644 --- a/hub_core/runtime/config.py +++ b/hub_core/runtime/config.py @@ -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) diff --git a/hub_core/runtime/data/legacy_topic_aliases.json b/hub_core/runtime/data/legacy_topic_aliases.json new file mode 100644 index 0000000..94f3fdc --- /dev/null +++ b/hub_core/runtime/data/legacy_topic_aliases.json @@ -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" + } + ] +} diff --git a/hub_core/runtime/legacy_topic_aliases.py b/hub_core/runtime/legacy_topic_aliases.py new file mode 100644 index 0000000..ab2cc23 --- /dev/null +++ b/hub_core/runtime/legacy_topic_aliases.py @@ -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") diff --git a/hub_core/runtime/repository_navigation.py b/hub_core/runtime/repository_navigation.py index 538de34..5f4fd07 100644 --- a/hub_core/runtime/repository_navigation.py +++ b/hub_core/runtime/repository_navigation.py @@ -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]]: diff --git a/hub_core/runtime/repository_navigation_routes.py b/hub_core/runtime/repository_navigation_routes.py index b87a174..d635530 100644 --- a/hub_core/runtime/repository_navigation_routes.py +++ b/hub_core/runtime/repository_navigation_routes.py @@ -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 diff --git a/tests/fixtures/a5-legacy-topics-2026-10-13.json b/tests/fixtures/a5-legacy-topics-2026-10-13.json new file mode 100644 index 0000000..e3030e1 --- /dev/null +++ b/tests/fixtures/a5-legacy-topics-2026-10-13.json @@ -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" + } + ] +} diff --git a/tests/test_legacy_topic_aliases.py b/tests/test_legacy_topic_aliases.py new file mode 100644 index 0000000..8ec38f7 --- /dev/null +++ b/tests/test_legacy_topic_aliases.py @@ -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