Note (2026-05-02). The Structural Spine is now a sub-area of the Page Graph subsystem. The typed-relation grammar (
relations:frontmatter,related_to,part_of, etc.) was removed in this update — seedocs/superpowers/specs/2026-05-02-page-graph-vs-knowledge-graph-design.md. The spine retains itscanonical_idassignment and validation, thecluster:hub-membership mechanism, save-time enforcement, and theMain.mdprojection. Page Graph edges are now strictly real wiki links.
🌐 Product overview: The structural spine on wikantik.com
— a plain-language walkthrough for readers and AI agents.
Wikantik exposes 1000+ pages through two MCP servers (/wikantik-admin-mcp, /knowledge-mcp), 23 REST resources, and full-text search. None of them let an agent ask "what does this wiki contain?" without first paying the cost of full-text search. The taxonomy that exists — cluster membership, tag assignments, type declarations, page-to-page relationships — is encoded in human-readable places (YAML frontmatter, Main.md prose) but never exposed as structured data that agents can query.
Concrete consequences today:
/api/pages and re-parse frontmatter client-side.RenamePageTool) silently invalidates every external reference — no stable identity survives the rename.Main.md is hand-curated prose that encodes the cluster taxonomy. It drifts from reality silently; every edit to frontmatter risks desync.This design introduces a structural spine — a small set of first-class APIs, a persistent canonical_id, and cluster-hub membership — so agents can navigate the wiki by shape, not by keyword.
canonical_id that survives renames.Main.md with generation from the structural index./api/structure/*) and MCP (/knowledge-mcp), so MCP-native and REST-native agents share one surface.HybridRetrieval).KnowledgeGraphService) — that service models LLM-extracted entities across chunks; the structural spine models pages, their cluster membership, and their rename-stable identity.canonical_id and cluster: live in YAML frontmatter, not inline Markdown.Every page gains one new frontmatter field.
---
title: Hybrid Retrieval
type: article
cluster: wikantik-development
tags: [retrieval, bm25, embeddings]
canonical_id: 01H8G3Z1K6Q5W7P9X2V4R0T8MN
---
canonical_id — a 26-character ULID
. Sort-friendly and URL-safe, fits the existing frontmatter parser, no UUID dashes. Immutable once assigned. Regenerating is a manual admin operation with a paper trail.The canonical_id field is optional during the bake-in period (Phase 1 below); it becomes required after the backfill migration lands.
Removed 2026-05-02. The
relations:frontmatter field and its typed-relation vocabulary (part-of,example-of,prerequisite-for,supersedes,contradicts,implements,derived-from) were removed. Page Graph edges are now strictly real wikilinks. If curated typed edges between concepts are needed in the future, they belong in the Knowledge Graph as admin-approved edges, not in page frontmatter.
An in-memory projection of structural data, maintained by a new StructuralIndexService:
ClusterIndex : Map<ClusterName, ClusterEntry{hubPageId, articleIds, updatedAt}>
TagIndex : Map<TagName, TagEntry{pageIds, count}>
TypeIndex : Map<PageType, Set<PageId>>
CanonicalIndex: Map<CanonicalId, PageDescriptor{slug, title, type, cluster, tags, summary, updated}>
SlugIndex : Map<Slug, CanonicalId> // for name→id resolution
The projection is derivable — authoritative state lives in frontmatter on disk + the pages table in Postgres. The index is a cache of derivations. Rebuild on bootstrap is mandatory; incremental maintenance on PAGE_SAVE / PAGE_RENAME / PAGE_DELETE events is the fast path.
Two tables track canonical-id stability across renames, used as a durable backstop when the in-memory projection is being rebuilt or absent.
Migration V013__canonical_ids_and_relations.sql (idempotent):
CREATE TABLE IF NOT EXISTS page_canonical_ids (
canonical_id CHAR(26) PRIMARY KEY,
current_slug VARCHAR(512) NOT NULL UNIQUE,
title VARCHAR(512) NOT NULL,
type VARCHAR(32) NOT NULL, -- hub | article | reference | runbook
cluster VARCHAR(128),
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE TABLE IF NOT EXISTS page_slug_history (
canonical_id CHAR(26) NOT NULL REFERENCES page_canonical_ids(canonical_id) ON DELETE CASCADE,
previous_slug VARCHAR(512) NOT NULL,
renamed_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
PRIMARY KEY (canonical_id, previous_slug)
);
CREATE INDEX IF NOT EXISTS ix_canonical_ids_type ON page_canonical_ids(type);
CREATE INDEX IF NOT EXISTS ix_canonical_ids_cluster ON page_canonical_ids(cluster);
GRANT SELECT, INSERT, UPDATE, DELETE ON page_canonical_ids, page_slug_history TO :app_user;
Removed 2026-05-02. The
page_relationstable (and its indexes) was dropped when typed relations were removed. Page Graph edges are now derived at query time from the liveReferenceManagerdata.
Frontmatter on disk remains the single source of truth — the DB is derived and can be rebuilt from page sources.
StructuralIndexServiceLives in wikantik-main (co-located with ContextRetrievalService and KnowledgeGraphService; shares the same JDBC datasource and event bus). API:
package com.wikantik.api.pagegraph;
public interface StructuralIndexService {
/* ------------------------------------------------- Clusters / tags / types */
List<ClusterSummary> listClusters();
ClusterDetails getCluster(String name);
List<TagSummary> listTags(int minPages);
List<PageDescriptor> listPagesByType(PageType type);
List<PageDescriptor> listPagesByFilter(StructuralFilter filter);
Sitemap sitemap();
/* ------------------------------------------------- Canonical identity */
Optional<PageDescriptor> getByCanonicalId(String canonicalId);
Optional<String> resolveSlugAtTimestamp(String canonicalId, Instant at);
Optional<String> resolveCanonicalIdFromSlug(String slug); // current-slug only
/* ------------------------------------------------- Lifecycle */
void rebuild(); // full scan from frontmatter
IndexHealth health(); // up-to-date, lag, last-rebuild
}
Removed 2026-05-02. The
outgoingRelations,incomingRelations, andtraverse(TraversalSpec)methods were dropped with the typed-relation grammar. Page Graph traversal (wikilinks) usesReferenceManagerdirectly.
StructuralIndexService subscribes to the existing WikiEventSubscriptionBridge (already used by the admin MCP server for resource subscriptions — McpServerInitializer line 148 is the wiring pattern to mirror).
PAGE_SAVE(slug, previousSlug?, newFrontmatter)
→ parse frontmatter (canonical_id, type, cluster, tags)
→ if canonical_id missing → auto-assign ULID and inject into frontmatter
→ if canonical_id is new → INSERT page_canonical_ids
→ if canonical_id exists and slug differs → INSERT page_slug_history(prev_slug), UPDATE current_slug
→ invalidate in-memory cluster/tag/type indexes for affected entries
→ publish StructuralIndexUpdated event (MCP resource subscribers re-read)
PAGE_RENAME(oldSlug, newSlug)
→ PAGE_SAVE already handles slug transition via canonical_id stability; rename is a degenerate case
PAGE_DELETE(slug)
→ DELETE page_canonical_ids WHERE current_slug=slug (CASCADE clears history)
→ invalidate in-memory indexes
On every WAR startup, StructuralIndexService.rebuild() runs in a background executor:
PageManager.getAllPages().FrontmatterParser).page_canonical_ids. If a page lacks canonical_id, synthesise one and surface the page in an /admin/unclaimed-canonical-ids view — do not auto-rewrite the source file (rewrites must go through a migration tool, see Phase 1 below).page_relations against frontmatter.{ "status": "rebuilding" } until bootstrap completes, and MCP tools return a clear rebuilding error with an ETA hint.Bootstrap SLA target: full rebuild of 2000 pages in < 5 s on the production box. Measured end-to-end, emitted as wikantik_structural_index_rebuild_duration_seconds (Prometheus).
All endpoints live under /api/structure/* and use the existing REST envelope convention ({data: …} or {error: …}, ACL-checked via RestServletBase).
GET /api/structure/clusters{
"data": {
"clusters": [
{
"name": "wikantik-development",
"hub_page": {
"canonical_id": "01H8G3Z1K6Q5W7P9X2V4R0T8MN",
"slug": "WikantikDevelopment",
"title": "Wikantik Development"
},
"article_count": 34,
"article_ids": ["01H8G3Z…", "01H8G3Z…", …],
"updated_at": "2026-04-24T13:45:11Z"
},
…
],
"generated_at": "2026-04-24T13:50:00Z"
}
}
GET /api/structure/clusters/{name}Full cluster detail: hub page summary, ordered article list with summaries, tag distribution within the cluster.
GET /api/structure/tags?min_pages=3Tag dictionary with counts and top-N representative pages per tag.
GET /api/structure/pages?type=hub&cluster=X&tag=Y&updated_since=ISO&limit=100&cursor=…Filtered page listing. Server-side paginated; cursor is opaque (encoded (updated_at, canonical_id)).
GET /api/structure/sitemap?shape=compactCompact machine-readable sitemap:
{
"data": {
"pages": [
{
"id": "01H8G3Z1K6Q5W7P9X2V4R0T8MN",
"slug": "HybridRetrieval",
"title": "Hybrid Retrieval",
"type": "article",
"cluster": "wikantik-development",
"tags": ["retrieval","bm25","embeddings"],
"summary": "Operator reference for Wikantik's BM25 + dense hybrid retrieval …",
"updated": "2026-04-22T18:02:10Z",
"url": "https://wiki.jakefear.com/wiki/HybridRetrieval"
},
…
],
"count": 2034,
"generated_at": "2026-04-24T13:50:00Z"
}
}
This is the ideal "agent prelude" payload — small enough to prime a cold agent with a map of the whole wiki (≈ 200 bytes × 2000 pages ≈ 400 KB uncompressed; well under one tool call's budget with gzip).
GET /api/pages/by-id/{canonical_id}Resolves a canonical ID to the current page. Same response shape as GET /api/pages/{slug}.
Removed 2026-05-02.
GET /api/pages/{canonical_id}/relationswas dropped with typed relations. For Page Graph traversal (wikilinks), useGET /api/pages/{slug}/backlinksor theget_backlinks/get_outbound_linksMCP tools on/wikantik-admin-mcp.
/knowledge-mcp)wikantik-knowledge/src/main/java/com/wikantik/knowledge/mcp/ gains five tools. All tool descriptions follow the lean-but-concrete convention already established in the knowledge MCP: one-sentence purpose, explicit input shape, explicit output shape, and at least one example payload in the JSON schema (per the tool-description upgrade specified in AgentGradeContentDesign.md).
| Tool | Description |
|---|---|
list_clusters | Return all clusters with hub page, article count, and last-updated timestamp. No inputs. |
list_tags | Return tag dictionary. Input: {min_pages?: int}. Output: [{tag, count, top_pages: [canonical_id]}](). |
list_pages_by_filter | Filtered page listing. Inputs: type?, cluster?, tag?, updated_since?, limit?, cursor?. Output: {pages: [PageSummary], next_cursor?}. |
get_page_by_id | Resolve a canonical_id to the current page, including latest slug + frontmatter + rendered body. |
All four tools land in wikantik-knowledge (the read-only server) — structural queries are retrieval-adjacent, not admin operations. The McpToolRegistry in wikantik-knowledge/…/mcp/KnowledgeMcpInitializer.java gets four registerTool(...) calls; follow the existing SearchKnowledgeTool wiring pattern.
Removed 2026-05-02.
traverse_relationswas dropped with typed relations. For wikilink traversal useget_backlinks/get_outbound_linkson/wikantik-admin-mcp.
Three new resource templates:
wiki://structure/clusters — snapshot of the cluster list (cacheable, Last-Modified based on latest PAGE_SAVE)wiki://structure/tags — snapshot of the tag dictionarywiki://structure/sitemap — compact sitemap (as above)These mirror the existing wiki://pages / wiki://recent-changes resources, giving subscription-aware MCP clients automatic updates through the existing WikiEventSubscriptionBridge.
Main.md generationToday Main.md is hand-curated prose. Post-structural-spine, it is generated.
wikantik-wikipages/src/main/templates/Main.md.mustache (or equivalent). Static boilerplate + a {{#clusters}} … {{/clusters}} loop.bin/generate-main-page.sh calls GET /api/structure/clusters against a freshly-built WAR (or, offline, reads frontmatter directly via a CLI in wikantik-extract-cli) and renders the template.wikantik-wikipages-builder's package phase, so Main.md always ships in sync with the frontmatter it summarises.docs/wikantik-pages/Main.pins.yaml file — the template reads both the structural output and the pins.No hand edits to Main.md after generation lands. apache-rat / pre-commit refuses commits that modify Main.md directly.
canonical_id to FrontmatterParser schema (optional initially).wikantik-extract-cli: wikantik-extract-cli assign-canonical-ids --dry-run | --write. Walks docs/wikantik-pages/, assigns a fresh ULID to each page missing one, rewrites frontmatter in-place (single commit, one file). Idempotent.V013 creates the three tables.StructuralIndexService runs in observe-only mode: builds the projection, exposes it at /api/structure/*, but does not yet require canonical_ids on every save.PAGE_SAVE for pages without canonical_id; surfaces in /admin/unclaimed-canonical-ids.Exit criterion: 100 % of pages in docs/wikantik-pages/ have canonical_id.
Removed 2026-05-02. Phase 2 (typed relations) was cancelled. The
relations:frontmatter grammar andProposeRelationsToolwere not implemented and will not be. See the note at the top of this document.
Main.md generation (half sprint)Main.md; commit the generated one.Main.md diverges.canonical_id required in the frontmatter validator (reject PAGE_SAVE without it; auto-assign and inject if absent).StructuralIndexService from observe-only to authoritative for structural queries.| Failure | Detection | Response |
|---|---|---|
| Projection out-of-date | health() reports lag_seconds > 60 | /api/structure/* returns X-Index-Staleness: <n>; MCP tools include stale_by_seconds in every response. No 5xx. |
| Projection empty (not yet rebuilt) | health().status = rebuilding | Endpoints return HTTP 503 with Retry-After. MCP tools return structured rebuilding error with ETA. |
| Duplicate canonical_id detected | On save, unique constraint fires | Reject save with actionable error: "canonical_id X already used by page Y. Pick a different ID or delete Y." |
| Two-way dependency conflict | Background check | Surface both pages in /admin/page-graph/conflicts. |
| DB outage | StructuralIndexService falls back to in-memory projection | Reads continue to serve; writes that require DB persistence return 503 {"fallback": "memory", "writes_deferred": true} and replay when DB returns. |
The overarching principle matches HybridRetrieval's fail-closed rule: structural queries degrade, they do not lie. Callers get stale data with an explicit staleness marker, or a clear unavailable error — never silently wrong data.
New Prometheus metrics (all gauges + counters exposed via the existing MeterRegistryHolder in wikantik-api/src/main/java/com/wikantik/api/observability/):
| Metric | Type | Purpose |
|---|---|---|
wikantik_structural_index_pages_total | gauge | Count of pages tracked in the projection |
wikantik_structural_index_lag_seconds | gauge | Seconds since last event processed |
wikantik_structural_index_rebuild_duration_seconds | histogram | Distribution of full-rebuild times |
wikantik_structural_api_requests_total{endpoint,status} | counter | Per-endpoint request volume |
Existing admin dashboards (/admin/observability) gain a "Structural Index" panel showing the three gauges + the rebuild histogram sparkline. A health check at /api/health/structural-index returns {status, lag_seconds, pages, relations}.
StructuralIndexServiceTest — event-driven updates: create page, rename, delete, verify projection + DB state.CanonicalIdAssignmentTest — auto-assign on save, immutability on rename, duplicate detection.In wikantik-it-tests:
canonical_id=X, rename to B, assert GET /api/pages/by-id/X still resolves.rebuild() duration, assert < 5 s (budget set at the start of the sprint; regression-gated).Run the harness against the dogfoodable cluster first:
Main.md from structural data.diff against the committed hand-curated version — expect convergence after a week of authoring work.Main.pins.yaml) or actual taxonomy fixes.| Phase | Effort | Blocks |
|---|---|---|
| 1 — canonical IDs | ~1 sprint (1 dev) | Phase 2 |
2 — Main.md generation | ~0.5 sprint | — |
| 3 — enforcement | ~0.25 sprint | Phase 1 complete |
Total: ~1.75 dev-sprints. The structural queries (Phase 1's observe-only mode) are useful on day one — agents gain list_clusters, list_tags, and sitemap immediately, even before canonical_ids are fully backfilled.
CHAR(26) so migration to UUIDv7 is a column-type change, not a schema rethink.cluster vs multi-cluster. Today cluster: <name> is a scalar. Multi-cluster membership is not supported — if needed, extend the frontmatter parser to accept cluster: [name1, name2] in a follow-up. No relation mechanism needed.Code that queries StructuralIndexService for cluster or verification data:
AgentHintsDeriver (in wikantik-main) — uses StructuralIndexService.getCluster(...) for hub designation and verificationOf(...) for the AUTHORITATIVE confidence bonus. Powers prefer_pages on the /for-agent projection.AgentGradeAuditResource (in wikantik-rest) — scans the index via listPagesByFilter to surface pages with weak agent-grade signals (no cluster, no intra-cluster inbound links, generic hub summary, missing or stale verification). Mounted at GET /admin/agent-grade-audit.