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:
parent
e8fb648f25
commit
9e4d5ba23c
10 changed files with 762 additions and 2 deletions
|
|
@ -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 `[]`.
|
||||
|
||||
|
|
|
|||
|
|
@ -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: <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
|
||||
the authoritative `helixforge.workload-reference/v1` identity pair. Migration
|
||||
`0004_workload_projection` stores only normalized transport rows and source
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
99
hub_core/runtime/data/legacy_topic_aliases.json
Normal file
99
hub_core/runtime/data/legacy_topic_aliases.json
Normal 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"
|
||||
}
|
||||
]
|
||||
}
|
||||
182
hub_core/runtime/legacy_topic_aliases.py
Normal file
182
hub_core/runtime/legacy_topic_aliases.py
Normal 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")
|
||||
|
|
@ -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]]:
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
103
tests/fixtures/a5-legacy-topics-2026-10-13.json
vendored
Normal file
103
tests/fixtures/a5-legacy-topics-2026-10-13.json
vendored
Normal 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"
|
||||
}
|
||||
]
|
||||
}
|
||||
267
tests/test_legacy_topic_aliases.py
Normal file
267
tests/test_legacy_topic_aliases.py
Normal 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
|
||||
Loading…
Add table
Add a link
Reference in a new issue