# A5 topic-spine comparison packet (draft) **Task:** STATE-WP-0098-T01 (A5) and STATE-WP-0098-T02 (A4 section) **Receiver:** hub-core `HUB-WP-0006-T06` **Captured:** 2026-10-13, read-only GETs against State Hub `primary/railiance01` **Cases:** `docs/evidence/a5-topic-spine-comparison-cases.json` (10 cases) **Status:** draft. Not yet sent to hub-core. No route, flag or deployment was changed. ## 1. Where the A5 and A4 item lists come from The counts "A4 / 22" and "A5 / 8" in `docs/retirement-cutover-slice-plan.md` come from the original slice plan (commit `613477b`). That plan grouped inventory items by capability: A5 is `topic-spine` (8); A4 is `work-coordination-ui` (14) plus `repo-ui` (8). The item list is `prj-state-hub-retirement/inventory/state-hub-disposition.yaml` (SHR-INV-0001). Items were matched on their `capability` field; none of them were guessed. **Correction to the framing.** The 8 A5 items are not 8 read endpoints: | Inventory id | Kind | Read? | | --- | --- | --- | | `route:GET:/topics/` | REST | read | | `route:GET:/topics/{topic_id}` | REST | read | | `mcp:get_topic` | MCP tool (wraps the two GETs plus `/progress`) | read | | `route:POST:/topics/` | REST | write | | `route:PATCH:/topics/{topic_id}` | REST | write | | `route:DELETE:/topics/{topic_id}` | REST | write | | `mcp:create_topic` | MCP tool | write | | `table:topics` | dataset | data | That leaves three read surfaces to compare. One more read surface is not in the inventory: the MCP resource `state://topics`. The three writes are out of scope for a read switch. They should be retired or frozen, not redirected, because the receiver accepts no classification writes. Classification fields on `/repos/` (`domain_slug`, `category`, tags, stake, mechanics, `topic_id`) belong to A1 in the inventory. They are still the real counterpart of the hub-core projection, so A5-C05 to A5-C07 compare them. ## 2. Callers **Legacy meter.** None of the topic routes is metered. `GET /legacy-meter/interfaces` returns 16 interfaces, all for workstreams, SBOM or messages. The weekly reviews up to `legacy-meter-weekly-review-20261013.json` do not mention topics. State Hub has no other per-route usage telemetry. **So there are no volume or last-seen figures.** To get them, register `rest_api:GET /topics/` and `rest_api:GET /topics/{topic_id}` in the meter before the switch window. That is a State Hub write and needs approval. **Code callers** (ripgrep over `/home/worsch/*`, excluding archives and workplans). Every direct caller is inside `state-hub`: | Caller | Route | Use | | --- | --- | --- | | `custodian_cli.py` (`statehub`) l.178, 481 | `GET /topics/?status=active` (+ `POST` l.184) | resolve topic for register/brief | | `statehub_register.py` l.851, 900 | `GET /topics/?status=active`, `POST /topics/` | repo registration | | `scripts/consistency_check.py` l.3398, 3784, 3969 | `GET /topics/{id}`, `GET /topics` | fix-consistency (C-06 etc.) | | `scripts/promote_intake.py`, `validate_repo_adr.py`, `normalize_attached_repo_workplans.py`, `update_agent_instruction_files.py`, `register_project.sh` | `GET /topics[/?status=active]` | slug/id/domain lookup | | `mcp_server/server.py` (`state://topics`, `get_topic`, l.208, 263, 2402, 2757) | `GET /topics`, `GET /topics/{id}` | MCP clients (agents) | | Dashboard: `tasks`, `decisions`, `dependencies`, `extensions`, `interventions`, `techdept`, `todo`, `workstreams` pages | `GET /topics/` | topic label lookup | The only hit outside `state-hub` is `kontextual-engine/core/cmis.py`. It builds a CMIS path, not a State Hub call, so it is not a caller. **Indirect alias consumers.** 126 repos carry a `topic_id` in `AGENTS.md` or `.claude/rules/repo-identity.md`. They use it in `/workplans/?topic_id=` (A2) and in progress events (B3). They never call `/topics/`, but they need the topic UUIDs to stay stable until those slices drop `topic_id`. ## 3. Legacy response shapes (captured live) - `GET /topics/?status=active` → 200, a list of 16 rows. Each row has `{id: uuid, slug, title, description, domain_slug, status, created_at, updated_at}`. All 16 are `active`. Headers: `Cache-Control: max-age=60`, `ETag`. - `GET /topics/{id}` → 200, the same fields plus `workstreams: [WorkstreamStub]`. An unknown id → 404. **Defect:** `workstreams` is always `[]`. The model relation was renamed `workplans`, but the schema still reads `workstreams`. No consumer can depend on its content. - MCP `get_topic(slug)` → `{topic: , recent_progress: []}`. - Repo classification (`GET /repos/`, 154 rows: 149 active, 5 archived) includes `id, slug, canonical_slug, domain_slug, secondary_domains, category, capability_tags, business_stake, business_mechanics, topic_id, classified_at, classified_by`. 58 repos have a `topic_id` and 96 do not; 23 have no `category`. - The topic ids and slugs are listed in the JSON case file (A5-C01/C02 use all 16). ## 4. Hub-core side and alias expectations - **Routes** (`hub_core/runtime/repository_navigation_routes.py`, GET only): `GET /ports/projections/repository-navigation/repositories` (filters `primary_domain`, `secondary_domain`, `category`, `capability_tag`, `business_stake`, `business_mechanic`; `cursor`; `limit` ≤ 500) and `GET /ports/projections/repository-navigation/facets/{kind}/{value}`. MCP: `query_repository_navigation`, `get_repository_navigation_facet`. 503 means no accepted projection; 409 means a stale cursor. - **Contract:** `helixforge.repository-navigation` 1.0.0 (`hub_core/contracts/repository_navigation/v1_0_0/`, `docs/repository-navigation-contract.md`). The output carries `repositories[]` keyed by registrar `repository_id`, `facets[]`, `legacy_topic_aliases[]`, provenance and `content_hash`. - **Identity.** Join on legacy repo `id` == `repository_id`. Repo Manager's registry import takes the UUID from State Hub `/repos/{slug}` (`repo_manager/repository_publisher.py`). That this holds for all 123 rows in the deployed registry is **unverified**. Slugs are labels, not identity. - **Topic aliases.** Each alias maps one State Hub topic UUID/slug to exactly one facet and must carry `expires_at` and a `residual_owner`. The expected mapping is `facet_kind = primary_domain` and `facet_value = topic.domain_slug`, with residual owner `STATE-WP-0079`. This mapping is lossy: - several topics share a domain (7 topics are `infotech`); - 4 repos sit under a topic of a different domain (`adaptive-pricing`, `commerce-canon`, `informed-decision`, `whynot-design`); - 96 repos have no topic. A topic is a grouping label, not a facet, so exact topic membership has no hub-core counterpart (A5-C04). - **Legacy fields with no counterpart:** topic `title`, `description`, `status`, `created_at`, `updated_at`, `workstreams`; in `get_topic`, `recent_progress` (B3) and workplans (A2); in repos, `classified_at` and `classified_by` (provenance replaces them). ## 5. Comparison cases Ten cases are defined in the JSON file: | Id | What it compares | Expected result | | --- | --- | --- | | C01/C02 | Topic list and topic-by-id against the aliases | equal once aliases exist | | C03 | Unknown topic | 404 vs empty facet, a documented difference | | C04 | Topic membership vs domain facet | subset, a documented difference | | C05 | Full per-repo classification parity on the id join | equal; cardinality gap 154 vs 123 to be listed | | C06 | `state-hub` spot check | equal | | C07 | Domain facet counts | equal | | C08 | MCP `get_topic` | partial | | C09 | Fields with no counterpart | none to compare | | C10 | Rollback read | equal to the 2026-10-13 capture | ## 6. Fallback and rollback The fallback is the current State Hub read routes, which stay unchanged throughout: `GET /topics/`, `GET /topics/{topic_id}`, and MCP `get_topic`/`state://topics`. Rollback means setting `RM_SLICE_TOPICSPINE` back to `off` and confirming with A5-C10. **Caveat:** `RM_SLICE_TOPICSPINE` does not exist in any code in State Hub, hub-core, rapp-core-hub or repo-manager. The slice plan calls the `RM_SLICE_*` names "proposed controls, not proof of deployed flags". So today the rollback is "callers keep or restore the legacy base URL". Someone must implement the flag, or name a per-caller switch instead, before the switch window. ## 7. Gaps blocking hub-core from running the comparison 1. **No alias source.** The hub-core runtime always emits `legacy_topic_aliases: []` (`repository_navigation.py` l.74). The only alias in the fixtures expired on 2026-09-30. C01, C02 and C08 cannot pass until someone owns alias seeding: hub-core config, or a State Hub export of the 16 topics. 2. **No flag.** `RM_SLICE_TOPICSPINE` is not implemented (see §6). 3. **No usage figures.** The topic routes are unmetered, so there is no volume or last-seen data. 4. **Candidate access not verified.** The base URL and auth for the candidate routes have not been checked from State Hub. The only source is the 2026-10-03 receipt (Helm rev 31, 123/123 accepted). Nobody has made a live candidate GET this session. 5. **Cardinality gap.** 154 legacy repos vs 123 published. The 31-row difference needs an explanation before C05 can be judged. 6. **The real switch targets are State Hub's own tools.** All direct callers are State Hub tools (CLI, consistency check, MCP, dashboard). They use topics for id/slug/domain lookup and for writes. A read switch to navigation facets only covers the domain lookup. ## A4 work/repo UI callers The 22 A4 items are Observable dashboard views in `state-hub/dashboard`, not API endpoints. They are served by `make dashboard` on the workstation (`:3000`) and viewed by the operator (Bernd). The inventory lists `repo-manager` as their owner, but RMGR-ADR-003 excludes a Repo Manager dashboard. The slice plan names "hub-core projection + UI clients" as the receiver. No hub-core page equivalents were checked (unverified). The backing reads below come from each page's `apiFetch`/`fetch`/`FileAttachment` calls. | View | Backing reads | Proposed owner to ask | | --- | --- | --- | | `/tasks`, `/tasks/*`, `/todo`, `/interventions`, `/workstreams`, `/workstreams/*`, `/dependencies` | `/tasks/`, `/workplans/`, `/workplans/index`, `/state/deps`, `/repos/`, `/topics/`, `/contributions/`, `/technical-debt/` | hub-core (work-record projection, A2) with repo-manager (file authority) | | `/decisions` | `/decisions/`, `/topics/` | hub-core; external link from **railiance-platform** (`docs/credential-change-approval.md`, CCR yaml `decision_dashboard_url`) | | `/goals` | `/domain-goals/`, `/repo-goals/`, `/domains/`, `/repos/` | hub-core / repo-manager (unverified owner) | | `/wsjf-triage` | `/progress/`, `/workplans/index` | hub-core (B3 progress) | | `/workplan-queue` | `/execution/*` | activity-core (C1) | | `/repos`, `/repos/*`, `/repo-sync`, `/contributions`, `/extensions`, `/techdept` | `/repos/`, `/domains/`, `/extension-points/`, `/technical-debt/`, `/contributions/`, `/workplans/`, build-time `data/repos.json`, `gitea-inventory.json` | repo-manager (A1 registry) via hub-core navigation | | `/sbom` | `/sbom/*` (Nexus-backed) | sbom-nexus (rapp-sbom-nexus); archived railiance-cluster/bootstrap docs link it | | `/policy/intake-doc`, `/policy/workstream-dod`, `/policy/repo-doi` | static policy text | policy-nexus (B4) | | `/suggestions` | page already removed (E1) | none; already retired | That makes 21 live views plus `/suggestions`, which is already gone. Pending approval, ask each owner for two things: - **A candidate read smoke.** The owner's endpoint returns the same rows for the backing reads above, compared on ids and counts. - **A reversible switch plan.** The page reads the candidate behind a toggle. Rollback re-points the page, or removes the redirect, to the retained State Hub UI, which stays unchanged. railiance-platform should also be asked where `decision_dashboard_url` should point after cutover. No messages have been sent.