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

203 lines
12 KiB
Markdown

# 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.