Decisions

ADR-155: sitemap.json schema + per-stack discovery contracts

Sub-ticket S0.1 of EPIC warp#1551 (fleet sitemap-as-source-of-truth). Phase 0 ships three sister ADRs that lock contracts before Phase 1 substrate ships: S0.1 (this ADR) names the JSON shape and the per-stack discovery…

#155

ADR-155: sitemap.json schema + per-stack discovery contracts

Status: Proposed Date: 2026-05-06 Related: warp#1551 (parent EPIC, fleet sitemap-as-source-of-truth), warp#1486 (sister structural pattern, docs-validate workflow), ADR-135 (sister scope, product-to-repos mapping)

Context

Sub-ticket S0.1 of EPIC warp#1551 (fleet sitemap-as-source-of-truth). Phase 0 ships three sister ADRs that lock contracts before Phase 1 substrate ships: S0.1 (this ADR) names the JSON shape and the per-stack discovery contracts; S0.2 names the /sitemap page UX; S0.3 names the CI gate semantics. Phase 1's two parallel discovery scripts (S1.1 SvelteKit, S1.2 Go) depend on S0.1 landing first — both emit the same JSON shape from a different stack and cannot be authored independently against an unspec'd contract.

Operator (jon, 2026-05-06) framed the gap on the parent EPIC: "all routes need to go on a sitemap page like Windows Explorer view, and this is for all sites. The system structurally checks and if a route is missing from it it doesn't pass." Today every fleet product hand-curates its top-nav, sidebars, breadcrumbs, and search-quickmenu against a manually-maintained route list per surface. Routes drift from nav silently — +page.svelte lands without a nav entry, a handler is removed without unlinking, a route is renamed without breaking deep-links elsewhere. The fix is the same shape as warp#1486's docs-validate gate, applied to routes: emit truth from filesystem + metadata, commit it, gate the CI, point consumers at the committed file. Without a fleet-wide schema, the Phase 2 anchor consumer (warp's /sitemap) and the Phase 4 cross-fleet aggregator each need per-stack adapters, and any shape drift between SvelteKit-emitted and Go-emitted JSON breaks the consumer in production.

Decision

A sitemap.json lives at every fleet product's repo root, auto-generated by a per-stack discovery script (Phase 1) and gated by a sitemap-validate CI workflow. The contract has three parts: the v1 JSON shape, the SvelteKit discovery rule, and the Go discovery rule.

v1 schema. Top-level required fields: version (integer, locked to 1), product (string slug joining to products.yaml per ADR-135), routes (array, alphabetical by path for stable diffs). Per-route required fields, all explicit (no omission):

{
  "version": 1,
  "product": "warp",
  "routes": [
    {"path": "/", "title": "Home", "kind": "page", "group": "primary", "role": null, "dynamic": false, "hidden": false},
    {"path": "/board/[id]", "title": "Item Detail", "kind": "page", "group": "primary", "role": null, "dynamic": true, "hidden": false},
    {"path": "/account", "title": "Account", "kind": "page", "group": "user", "role": "user", "dynamic": false, "hidden": false},
    {"path": "/account/admin/keys", "title": "Mint Keys", "kind": "page", "group": "admin", "role": "admin", "dynamic": false, "hidden": false},
    {"path": "/api/v1/items", "title": "Items API", "kind": "api", "group": "primary", "role": null, "dynamic": false, "hidden": false}
  ]
}

Field meanings: path is the literal user-typed route, dynamic segments preserved verbatim (/board/[id], NOT /board/<slug>); title is the display name in nav and /sitemap tree; kind is one of "page" (navigable), "api" (inventoried, not in tree), "redirect" (30x source); group is "primary" / "user" / "admin" / custom; role is "user" / "admin" / null (drives lock-icon in /sitemap, locked separately by S0.2); dynamic is computed from path bracket-segments; hidden: true inventories a route for the gate without rendering it. v1 → v2 is a breaking change requiring a follow-up ADR, dual-emit migration window, and explicit consumer version-gating.

SvelteKit discovery contract. The S1.1 script walks src/routes/**/+page.svelte (and +page.ts / +page.server.ts siblings). Each +page.svelte produces one route entry; the path is derived by stripping src/routes and /+page.svelte, with [slug] and [id] segments preserved verbatim. Per-route metadata is read from +page.ts export const sitemap = { title, group, role, kind, hidden }; absent fields fall back to derived defaults (title from humanised filename, group "primary", role null, kind "page", hidden false). dynamic is computed from path, never author-overridable. Layouts (+layout.svelte, +layout.ts, +error.svelte) are NEVER inventoried — they are not destinations. +server.ts files emit kind: "api" so the gate validates them without crowding the tree. SvelteKit's (group) directory syntax is collapsed in the emitted path (src/routes/(app)/board/+page.sveltepath: "/board"); the grouping is captured by group: metadata if surfaced.

Go discovery contract. The S1.2 library exposes RegisterSitemap(path, meta) (signature locked in S1.2's implementation ADR; this ADR locks the contract shape). Each handler-mount call site that wants sitemap visibility calls the helper alongside its router-mount call:

r.Get("/board", boardHandler)
sitemap.Register("/board", sitemap.Meta{Title: "Kanban", Group: "primary", Kind: "page"})

A cmd/sitemap-emit (or go run ./tools/sitemap) executable triggers package init() calls (which call RegisterSitemap), gathers the registry, and writes sitemap.json. Pages-only by default: handlers without RegisterSitemap are silently absent, which is intentional — most API handlers don't belong in /sitemap's tree, and the explicit call IS the author's signal that this handler is user-navigable. APIs that DO want inventory call RegisterSitemap(path, sitemap.Meta{Kind: "api"}). Keeping APIs in routes[] (rather than a top-level apis[] field) keeps the schema flat and lets a single CI gate validate both surfaces.

The choice of explicit registration over implicit chi/echo introspection is justified in Alternatives.

Consequences

What becomes easier. The two Phase 1 discovery scripts ship in parallel against an agreed contract; neither blocks the other. The /sitemap page (Phase 2) reads one shape regardless of which stack emitted it, so the renderer is one component, not two. The CI gate (Phase 1 S1.3) is one shape — "discover, diff against committed, fail on drift" — independent of stack. Cross-fleet aggregation (Phase 4) is a directory walk over per-product sitemap.json files joined against products.yaml (ADR-135); no per-stack adapters needed.

What becomes harder. Every fleet product now ships with one extra committed file and one extra CI workflow. Discovery scripts are stack-coupled — a future stack (Python/FastAPI, Rust/axum, Elixir/Phoenix) needs its own discovery script before that stack's products can ship under this gate. The schema is locked at v1; bumping it requires a follow-up ADR, the v1→v2 migration path, and coordinated rollout across every emitter and consumer.

What we sign up to operate. The v1 schema is fleet-wide; changing it requires a v2 ADR, dual-emit during migration, and explicit consumer version-gating. The two discovery scripts are devtools artefacts (S1.1 in devtools, S1.2 in gyrum-go) and live under devtools' release cadence. The CI gate template is a gyrum-setup-propagated artefact; rolling out the gate fleet-wide is the same shape as warp#1463's docs-validate fleet-rollout.

The RegisterSitemap choice for Go. Explicit registration is one extra line per handler-mount call site. The benefit is correctness across heterogeneous handler styles in the fleet: chi-based, echo-based, gorilla/mux-based, and net/http-direct services all use the same registration helper, so the discovery script is one shape rather than four. Implicit introspection over chi or echo is brittle when products mount routers in non-standard shapes (sub-router under /api/v1 with its own root handler), and produces false positives on middleware-only handlers that exist for plumbing rather than user navigation. Explicit registration aligns with the broader rule that route metadata (title, group, role) is author-declared, not inferred — it would be inconsistent to author-declare metadata while inferring presence.

Alternatives considered

  • Implicit chi/echo router introspection for Go. Walk the router's route tree at runtime and emit every registered path. Lost: false positives (middleware-only handlers, internal plumbing routes), false negatives (sub-routers mounted under non-standard paths, custom mux implementations), and per-router fragility (chi's tree differs from echo's, gorilla/mux is a third shape). Explicit registration is one helper that works across every Go handler style.

  • Author the schema first; defer per-stack discovery to a later ADR. Ship S0.1 as JSON shape only; let S1.1 and S1.2 each propose their own discovery contract during implementation. Lost: the two scripts could land with subtle shape divergences (SvelteKit [id] rendered differently from Go :id), caught only at consumer-integration time. Locking BOTH discovery contracts here forces alignment up-front.

  • Single discovery library spanning both stacks. A polyglot library that walks SvelteKit AND introspects a Go router. Lost: high implementation cost for negligible benefit; the two stacks have nothing in common beyond the output JSON. Per-stack scripts emitting the same JSON shape is the looser coupling that survives stack divergence.

  • Layouts as kind: "layout" entries. Inventory layouts alongside pages with a separate kind. Lost: deadweight in every consumer (the /sitemap tree, every nav consumer, the cross-fleet aggregator) for a use case that does not yet exist. Excluded by default; future use case can revisit via v2.

  • Hidden routes filtered out of the inventory entirely. Treat hidden routes as not-in-inventory at all. Lost: the CI gate would not catch the failure mode where a hidden route is renamed, breaking deep-links that target the old hidden path. Including hidden routes (with hidden: true) preserves coverage while keeping the rendered tree clean.

  • Per-stack JSON shapes joined at consume time. Each stack emits its native shape; consumers normalise on read. Lost: the /sitemap renderer becomes per-stack; the cross-fleet aggregator becomes a fan-out shape-converter; the gate becomes per-stack. The whole point of S0.1 is removing per-stack adapters from every consumer.


Supersedes: none — extends EPIC warp#1551; sister to S0.2 (UX shape ADR, separate child) and S0.3 (gate semantics ADR, separate child) of the same EPIC. Superseded by: {{leave blank until a later ADR reverses this one}}