state-hub/docs/a5-topic-spine-comparison-packet.md
tegwick 6d53e707e2
All checks were successful
CI Smoke / host-smoke (push) Successful in 0s
CI Smoke / container-smoke (push) Successful in 4s
STATE-WP-0098: A5 comparison packet, A4 owner map, inbox change-feed contract draft
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
2026-10-13 23:18:36 +02:00

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 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: <GET /topics/{id}>, recent_progress: [<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.