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
12 KiB
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 areactive. Headers:Cache-Control: max-age=60,ETag.GET /topics/{id}→ 200, the same fields plusworkstreams: [WorkstreamStub]. An unknown id → 404. Defect:workstreamsis always[]. The model relation was renamedworkplans, but the schema still readsworkstreams. No consumer can depend on its content.- MCP
get_topic(slug)→{topic: <GET /topics/{id}>, recent_progress: [<progress>]}. - Repo classification (
GET /repos/, 154 rows: 149 active, 5 archived) includesid, slug, canonical_slug, domain_slug, secondary_domains, category, capability_tags, business_stake, business_mechanics, topic_id, classified_at, classified_by. 58 repos have atopic_idand 96 do not; 23 have nocategory. - 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(filtersprimary_domain,secondary_domain,category,capability_tag,business_stake,business_mechanic;cursor;limit≤ 500) andGET /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-navigation1.0.0 (hub_core/contracts/repository_navigation/v1_0_0/,docs/repository-navigation-contract.md). The output carriesrepositories[]keyed by registrarrepository_id,facets[],legacy_topic_aliases[], provenance andcontent_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_atand aresidual_owner. The expected mapping isfacet_kind = primary_domainandfacet_value = topic.domain_slug, with residual ownerSTATE-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).
- several topics share a domain (7 topics are
-
Legacy fields with no counterpart: topic
title,description,status,created_at,updated_at,workstreams; inget_topic,recent_progress(B3) and workplans (A2); in repos,classified_atandclassified_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
- No alias source. The hub-core runtime always emits
legacy_topic_aliases: [](repository_navigation.pyl.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. - No flag.
RM_SLICE_TOPICSPINEis not implemented (see §6). - No usage figures. The topic routes are unmetered, so there is no volume or last-seen data.
- 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.
- Cardinality gap. 154 legacy repos vs 123 published. The 31-row difference needs an explanation before C05 can be judged.
- 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.