The docsite is built with doc-kitty, consumed as a pinned git dependency, at directory URLs with every legacy URL redirected
Status: Proposed (2026-10-31). The operator accepts it by merging the pull request that carries it.
What was ruled and what is derived.
- Ruled by the operator (2026-10-31, #6100 decisions comment). Numbered as in the comment; "ruling N" below refers to this list.
- URL scheme. Directory URLs; every current
*.htmlURL (about 830) redirects to its new address; no baseline URL breaks.- Section index file.
README.md. The 56 folders whose index isindex.mdare renamed, and the 4 folders holding both files are resolved.- Features only DocFX has. The kitty-specs mission dashboards, their JSON-LD and the template's
main.jsare dropped for now. doc-kitty's generatedllms.txtreplaces the hand-written one and keeps the version-stance text. An upstream issue asks for mission and glossary pages as a hub-style grid of cards.- What is published.
plans/(restricted feeds) anddevelopment/(its own section). Engineering-notes, investigations and research are draft or unpublished. Archive, reports, convergence and the pages about the retired hosted and sync surfaces are unpublished.- Proposed ADRs take
doc_status: active.- ADR index. doc-kitty gains support for date-named ADRs in era folders, with a frontmatter
statusfallback; core's era indexes stay until then.- #3891 stays separate; #2358, #3914, #2361 and #2362 move under #6100.
- #648 stays standalone.
- #2302 is written after cutover, in spec-kitty-open-packs.
- Toolkit. The build consumes
spec-kitty/doc-kittydirectly; this record says how: a pinned git dependency or checkout, or the package doc-kitty publishes.- Collisions. Moves and renames are allowed; each open docs PR resolves its own conflicts when it merges.
- Glossary. #3024 settles the source of truth first; one
definitions.yamlis then generated from it.- Operator steer relayed with the step-1 brief (2026-10-31), not in the comment: prefer not to depend on a separately authorised package. D-2 weighs it.
- Derived: every decision below marked (derived), and the spike results, which are evidence, not rulings.
Date: 2026-10-31
Deciders: Stijn Dejongh (operator). Derived clauses are proposed by this record for the operator's sign-off.
Technical Story:
- Epic #6100 (step 1: this ADR and its spike).
- Absorbs #2358 (a standing moves ledger and a living URL baseline). Places #3914, #2361 and #2362 under #6100.
- Builds on ADR 2026-06-27-1 (in-file frontmatter is the page-metadata authority) where it still applies.
- Spike evidence:
kitty-ops/research/01M6947JAAGSMJTM6ST9D429BG/spike-evidence.md(onmainonce this record merges;kitty-ops/is outside the published site).
Context and Problem Statement
docs.spec-kitty.ai is built by DocFX (.NET) from docs/ and then post-processed by six Python scripts, in seven steps, in .github/workflows/docs-pages.yml: kitty-specs dashboard generation, SEO metadata, glossary auto-linking, PlantUML rendering, meta-refresh redirect stubs and two verifiers. The pipeline runs on a push to main or 2.x and on manual dispatch. No pull request builds the site, so a render defect is found after merge. The workflow's own comments record this, and they still name a docs-build-pr.yml that no longer exists.
doc-kitty is Spec Kitty's own Astro + Starlight docs toolkit. It owns the frontmatter contract, the section registry, llms.txt, the agent and OKF exports (OKF: doc-kitty's export of the docs as plain Markdown with metadata, for agents), and the link and redirect gates that core rebuilt piecemeal around DocFX. Epic #6100 moves the docsite onto it. This record fixes the decisions every later step of #6100 depends on: how core consumes doc-kitty, where the site lives, what URLs it serves, how no published URL breaks, and how the move is gated, run in parallel, rolled back and finished.
Decision Drivers
- No published URL breaks. About 830 URLs are live today: 668 current pages (derived from
docfx.json's content globs against the tree at this record's base) plus 163 legacy URLs already redirected byscripts/docs/redirect_map.yaml. - One authority per concern. doc-kitty owns rendering and its gates; core owns its content, its URL ledger and its publication choices. Core does not fork or patch the toolkit.
- No credential that is not already in the workflow. No PAT. The operator also steered away from depending on a separately authorised package (the step-1 brief).
docs/stays where it is.spec-kitty docs queryreadsdocs/development/docs-retrieval-index.yaml, and agents, scripts and links across the repository addressdocs/paths.- A defect is caught on the pull request, not after deploy.
Considered Options
For consuming doc-kitty (ruling 10 leaves the choice to this record):
- A pnpm git dependency pinned by commit,
github:spec-kitty/doc-kitty#<sha>&path:/src, recorded insite/pnpm-lock.yaml. - A CI checkout of
spec-kitty/doc-kittyat a pinned SHA, linked asfile:orlink:. - doc-kitty's published package,
@spec-kitty/doc-toolkiton GitHub Packages. - Vendoring the toolkit source into core.
For the site's place in the repository: a site/ sub-project reading ../docs, or moving the content under the site project.
Decision Outcome
Chosen options: a site/ sub-project that reads ../docs, consuming doc-kitty as a pnpm git dependency pinned by commit, deployed at the domain root with directory URLs and a generated redirect for every legacy URL. The decisions follow.
D-1: doc-kitty replaces DocFX; site/ is a sub-project and docs/ stays at the root (replacement: ruling 10 and the epic; layout: derived)
- The docsite is built by doc-kitty. DocFX is retired as set out in D-13.
site/holds the Astro project:package.json,pnpm-lock.yaml,astro.config.mjs,src/content.config.ts,src/pages/llms.txt.ts,public/(withCNAMEandrobots.txt),url-baseline.txt,moves.yamlandredirects.json.defineDocKittyIntegrations({ docsDir: '../docs', indexBasename: 'README', … })anddocKittyDocsLoader({ base: '../docs' }). The two must agree; doc-kitty requires it.- Index-file sequencing. DocFX emits a section page only from
index.md. Renaming a folder'sindex.mdtoREADME.mdwhile DocFX still deploys would make that section's/dir/and/dir/index.htmlreturn 404 on the live site.- During the parallel run (D-13), the site sets
indexBasename: ['README', 'index'], as doc-kitty's own example does. - The 4 folders holding both files are resolved before the scaffold build, keeping
index.mdas the index, because two index files in one folder collapse to one route. - After DocFX retirement (D-13 step 5), all 60
index.mdfiles becomeREADME.md(the 56 of ruling 2, plus the 4 resolved folders, which keepindex.mduntil then);indexBasenamethen narrows to'README'. The rename waits for retirement, not just cutover, so that reverting the cutover still redeploys a DocFX site whose sections all build. - Every
dockitty-*gate is run with--astro-config astro.config.mjs. Without it, a gate looks for the site config in the parent of the docs root (the repository root), finds none and falls back toREADMEalone, so it would misread the 60index.mdfiles during the parallel run.
- During the parallel run (D-13), the site sets
docs/stays at the repository root. That keepsspec-kitty docs query, every in-repo link and everyscripts/docstool that readsdocs/working unchanged.docs/_meta/sections.yamlis the section registry (doc-kitty's shape); it is authored content, not generated.- Spike: a build with
docsDir: '../docs'from a siblingsite/project emitted every page, with eachREADME.mdas its section index. The doc-kitty survey had flagged this layout as untested upstream, so the scaffold step adds a fixture for it upstream (gap list, D-14).
D-2: doc-kitty is consumed as a pnpm git dependency pinned by commit (derived)
site/package.json declares:
"@spec-kitty/doc-toolkit": "github:spec-kitty/doc-kitty#<40-character commit SHA>&path:/src"
pnpm records the commit, the subpath and the repository in site/pnpm-lock.yaml. CI installs with pnpm install --frozen-lockfile. Spike: this resolved from the public repository with no token, built the site, and put the toolkit's dockitty-* gate binaries on the path.
| Git dependency by commit (chosen) | CI checkout at a SHA | GitHub Packages (@spec-kitty/doc-toolkit) |
Vendoring | |
|---|---|---|---|---|
| Pin authority | the lockfile, one place | a SHA in the workflow, plus a file: path the lockfile cannot verify |
the lockfile (version + integrity) | the copied tree |
| Local build | pnpm install |
needs a second clone at the right path | needs a PAT with read:packages in ~/.npmrc |
works |
| CI credential | none (public repository) | none (public repository) | packages: read, and package access granted to this repository |
none |
| Separately authorised package | no | no | yes (rejected: the operator's steer, and a PAT locally) | no |
| Drift from upstream | none | none | none | the copy forks |
The CI checkout duplicates the pin into a place a lockfile does not see and makes local and CI builds differ. GitHub Packages is the package the operator did not want to depend on, and it needs a token locally. Vendoring forks the toolkit, against D-14.
Bump procedure (one pull request per bump, titled docs(site): bump doc-kitty to <tag> (<short sha>)):
- Choose a doc-kitty release tag (
v*) and resolve it to its commit:git ls-remote https://github.com/spec-kitty/doc-kitty refs/tags/<tag>^{}. Pin a commit a release tag points at, unless a fix the site needs is not yet released; then say so in the pull request. - Replace the SHA in
site/package.json, alignastro,@astrojs/starlightand@astrojs/markdown-remarkwith the peer ranges the newsrc/package.jsondeclares, and runpnpm installinsite/. - Run the build and every gate of D-11 locally, and read doc-kitty's
CHANGELOG.mdbetween the two tags for frontmatter or config changes. - Open the pull request; the required build gate (D-11) must pass.
A make docs-site-bump REF=<tag> target that does steps 1 and 2 is part of the scaffold step.
D-3: CI uses no PAT (derived)
- Install: doc-kitty is public, so
pnpm installneeds no credential. - Deploy: GitHub Pages deploys through OIDC (
pages: write,id-token: write), as today. - Pages probe: the existing probe keeps using
${{ github.token }}. - If doc-kitty ever becomes private, this record is amended before any credential is added. A PAT is not the fallback.
D-4: the site deploys at the domain root (derived)
astro.config.mjs sets site: 'https://docs.spec-kitty.ai' and base: '/', and passes base: '/' to defineDocKittyIntegrations. CNAME moves to site/public/CNAME.
Redirect targets are written base-less (/guides/x/). doc-kitty's example prefixes targets with the base; with base: '/' that produces //guides/x/, so the site does not copy that line.
D-5: the URL contract, the baseline ledger and the moves ledger (absorbs #2358) (URL shape: ruling 1; ledgers and generator: derived)
URL shape. Every page is served at a directory URL (/guides/getting-started/, Astro's default build.format: 'directory').
Baseline ledger. site/url-baseline.txt is the committed list of every URL the site has ever published (doc-kitty's format: one site-relative URL per line, # comments). It is a living ledger:
It is seeded once, before cutover, from the union of:
- a site walk of the last DocFX build (
scripts/docs/capture_baseline_urls.py --site-dir), which is the canonical capture and replaces the current 180-URL manifest; - every key of
scripts/docs/redirect_map.yaml.
The derive-from-source run for this record counts 668 + 163 = 831 URLs.
- a site walk of the last DocFX build (
It grows on publish: a new page's URL is appended in the pull request that publishes it.
An entry is removed only with a recorded amendment (date, pull request, ruling), as
redirect_baseline_urls.jsonalready does.
Moves ledger. site/moves.yaml is a standing, repository-level, append-only list of old route -> new route moves. It replaces the mission-scoped occurrence_map.yaml binding that #2358 reports.
Redirect declarations. site/redirects.json (Astro's redirects map) is generated from the two ledgers by one script, never edited by hand:
- Every legacy
X.htmlpage URL redirects to the directory URL of its current page,X/, or to the page thatmoves.yamlsays replaced it. A legacydir/README.html(DocFX published aREADME.mdindex under its own name; 16 such URLs today) redirects todir/. - Redirect chains are collapsed: every key points at a final, live route.
- A legacy
dir/index.htmlURL gets no declaration whendir/is a live section. With the directory build format, the section's page is alwaysdist/dir/index.html, whether its source isindex.mdorREADME.md, and GitHub Pages serves it at that URL. A declaration there would collide with the page. Adir/index.htmlkey whose section no longer exists is declared like any other key; 11 such keys sit in today's map (1x/,2x/,3x/,archive/,archive/1x/,archive/2x/,explanation/,how-to/,recovery/,reference/,tutorials/). Spike: a key/tutorials/index.htmlemitteddist/tutorials/index.html/index.html, the gate covered it, and the emulation resolved it. - A baseline URL whose page is unpublished (ruling 4: draft, excluded or retired) redirects to the nearest published page, normally its section hub (for example
/plans/research/x.html→/plans/). Ruling 1 says no baseline URL breaks, and ruling 4 says the page is not published; a redirect to the hub satisfies both. 34 such URLs come fromplans/alone (engineering-notes 21, investigations 11, research 2). An entry leaves the baseline only by a recorded amendment that cites an operator ruling.
Spike:
- Astro emits a
.htmlredirect key as a directory stub,dist/a/b.html/index.html. - Under the documented GitHub Pages serving rules (emulated),
/a/b.htmlreturns a 301 to/a/b.html/, then the stub's meta refresh, then the page. - doc-kitty's
check-redirect-coverage.mjscovers these keys, including a two-hop chain, without change. - The real Pages service was not exercised. The canary in D-13 confirms it before cutover, so the GitHub Pages leg is UNVERIFIED until then.
Fallback (evaluated, not chosen): flat stubs (dist/a/b.html as a file) remove the 301 hop. The emulation serves them correctly, but doc-kitty's coverage gate cannot see them, and they need a post-build step outside Astro. Use them only if the canary shows the directory 301 misbehaving; until upstream gap (b) lands they also need the gate override below.
Gate gap and interim. doc-kitty's gate looks only for <path>/index.html, so it reports a legacy dir/index.html URL as uncovered even though the page is there. Until upstream gap (b) lands, the gate runs with a generated --redirects override mapping each dir/index.html to dir/. The override is used by the gate alone, never by the site, and the gate still checks that the target is live. The spike's mutant run, with a dead target, failed as expected.
Full-corpus spike. The generator rules above, run on the real ~831-URL baseline against a doc-kitty build of the real docs/ tree (after the D-7 fixes, see the spike evidence), declared 789 redirects: 753 to the URL's own page, 36 to a section hub (rule 4), with all 163 legacy map entries collapsed to their final page. 42 legacy dir/index.html URLs needed no declaration (rule 3). The coverage gate, run without --docs, reported 831/831 covered, and the GitHub Pages emulation resolved all 831.
No silent excuse. Run with --docs and drafts off, the gate accepts a baselined URL that ends at a draft page as "unpublished". So the gate also runs a second time without --docs, which excuses nothing; rule 4 makes that run pass.
Dropped dashboards. The kitty-specs dashboard URLs (/kitty-specs/**, including /kitty-specs/glossary.html) are generated, were never part of the baseline, and are not in the ~830. They are dropped (ruling 3). The site's 404 page names the retirement for any /kitty-specs/ path.
D-6: scripts/docs keep / port / drop (derived)
"Keep" means the script stays as it is and keeps its tests. "Port" means its job moves to doc-kitty or to site/, and the Python script is deleted in the step that ports it. "Drop" means it is deleted at DocFX retirement (D-13), or sooner when nothing calls it.
| Script | Verdict | Where its job goes |
|---|---|---|
docs_index.py, _inventory.py, inventory_lockfile.py |
keep | They feed spec-kitty docs query and the page inventory, from docs/ frontmatter. Updated for README.md indexes. |
freshen_adr_inventory.py |
keep (until gap a) | Keeps the era indexes (ruling 6); retired when doc-kitty's ADR index lands. |
check_changelog_style.py, sync_changelog.py, check_spelling.py |
keep | Source-level checks with no DocFX dependency. |
build_cli_reference.py, check_cli_reference_freshness.py, check_slash_command_freshness.py, _typer_walker.py, _render.py, _guards.py |
keep | They generate or check source pages. |
check_docs_freshness.py, version_leakage_check.py |
keep | Re-pointed at README.md indexes. |
audience_resolver.py, related_validator.py, description_length_check.py |
port | dockitty-validate-frontmatter (audience, related, description length). |
relative_link_fixer.py (link check) |
port | dockitty-check-links docs --strict. The one-shot fixer mode is dropped. |
capture_baseline_urls.py |
keep for one run, then drop | Seeds site/url-baseline.txt from the last DocFX build. |
redirect_stub_generator.py, redirect_map.yaml, redirect_baseline_urls.json |
port | site/redirects.json generator + doc-kitty's check-redirect-coverage. The map and manifest feed the seed, then retire. |
seo_postprocess.py, seo_verify.py |
port | Starlight emits description, canonical and Open Graph; dockitty-assert-no-broken-links and the build artifact checks verify the output. Any check doc-kitty lacks is an upstream gap. |
glossary_linker.py, glossary_page/ |
port (after #3024) | doc-kitty's Contextive glossary and auto-link (D-10). |
plantuml_render.py, plantuml_invoke.py, plantuml_pins.json |
port, then drop at retirement | doc-kitty's build-time diagram render (its ADR-0040) against a self-hosted PlantUML server; see D-11. They and their tests are deleted with DocFX. |
generate_kitty_specs_docs.py |
drop | Dashboards dropped (ruling 3). |
_published_pages.py |
port | docs/_meta/sections.yaml plus the publication rules of D-8. |
frontmatter_backfill.py, frontmatter_backfill_sections.yaml, adr_converter.py |
keep for the migration, then drop | Reused by the frontmatter migration of D-7. |
bulk_ref_rewrite.py, rename_reconcile.py, touched_set_gates.py |
keep for the README rename, then drop | Reused by the rename step; their binding to a closed Mission's occurrence_map.yaml is replaced by site/moves.yaml. |
docs/docfx.json, every toc.yml, docs/templates/spec-kitty/ (incl. main.js) |
drop | At DocFX retirement. |
D-7: the frontmatter contract mapping (derived)
doc-kitty's gate requires title, description, doc_status, updated and kind. The current corpus (913 Markdown files) maps as follows, in one mechanical migration per row group:
| Field | Today | On doc-kitty |
|---|---|---|
kind |
absent everywhere; 209 pages carry a lowercase Divio type |
kind from type (how-to → How-To, explanation → Explanation, reference → Reference, tutorial → Tutorial); section index pages get kind: Hub; ADRs get kind: ADR; the rest are classified in the migration step, and any page left unclassified is a migration failure. type stays only where it carries doc-kitty's type meaning. |
audience |
a path string such as docs/context/audience/internal/maintainer.md |
[{ profile: internal/maintainer, guidance_text: … }], which needs nested persona slugs (gap d). Personas stay under docs/context/audience/. |
doc_status |
active, deprecated, draft, superseded, durable, plus closeout, point_in_time and proposal |
The five canonical values pass. closeout and point_in_time map to durable; proposal maps to draft. |
updated |
a date ('2026-10-26') on 724 pages; absent on the rest |
Normalised to an instant (2026-10-26T00:00:00Z); ADRs take theirs from date. |
| ADR metadata | status (Accepted, Proposed, Superseded) and date, with no doc_status on all 226 ADRs that predate this record |
kind: ADR; doc_status: Accepted → active, Proposed → active (ruling 5), Superseded → superseded; updated from date. status and date stay, because core's era indexes read them. This record already carries doc_status: active. |
related |
repository paths (docs/changelog/index.md) on 413 pages, 32 of them outside docs/ (kitty-specs/, tests/, pyproject.toml) |
doc-kitty page slugs (changelog, adr/3.x/<file> without .md). References outside docs/ move to external_references; references to unpublished pages follow D-8. |
| Excluded trees | archive, reports and convergence (242 files); 31 lack title or description |
Only the build-schema minimum: title, description, an audience list where present (D-8). |
| Index files | index.md in 60 folders; 4 also hold a README.md (adr/1.x, adr/2.x, adr/3.x, architecture) |
Before the scaffold, the 4 folders get one index each, index.md. The three ADR-era README.md files are redirect stubs pointing at their index.md; they are deleted, and their README.html URLs redirect to /adr/<era>/. architecture/README.md is a 131-line landing page and is folded into architecture/index.md. After DocFX retirement, all 60 index.md become README.md and indexBasename is README only (sequencing: D-1). |
ADRs must gain doc_status. doc-kitty treats a page with no doc_status as a draft, and a drafts-off build does not publish it. Without the ADR row above, every ADR would vanish from the live site.
The immutable corpus. ADRs, plans, reports and changelog entries keep their body byte for byte. A frontmatter-only change made by a recorded, mechanical migration is allowed. The migration step carries a test that hashes every immutable file's body before and after and fails on any difference.
One body change is allowed, and it needs the operator's sign-off with this record: a mechanical link-target rewrite driven by site/moves.yaml. The README rename alone touches links in 43 immutable files. The hash test normalises link targets through the moves ledger before comparing, so a rewritten link passes and any other change fails.
D-8: what is published (derived from ruling 4)
docs/_meta/sections.yamlregisters every published section.developmentis its own section.plansis registered with restrictedfeeds(nollms, noagent, norss).plans/engineering-notes/,plans/investigations/andplans/research/takedoc_status: draft. The live build runs withDK_PUBLISH_DRAFTS=off, so they are not published. doc-kitty defaults the setting toon, sosite/.env.productionsets it tooff, in one place; workflows do not repeat it.- An unpublished page's old URL redirects to its section hub (D-5 rule 4).
archive/,reports/andconvergence/, together with the pages about the retired hosted and sync surfaces (listed in the publication step), are not published.- Exclusion mechanism. doc-kitty has no section exclusion (gap f). Until it does,
site/src/content.config.tswrapsdocKittyDocsLoaderwith a filter that drops those route prefixes. Spike: with the filter, an archiveddoc_status: deprecatedpage was absent from pages, hub links, the sitemap,llms.txt, the OKF export and the content graph. Without it, the same page was built and linked from two hubs.- Limit of the shim. The filter drops entries only after doc-kitty's loader has validated them, so an excluded page must still pass the build schema (
title,description, anaudiencelist). In the full-corpus spike, 31 excluded pages (reports 24, convergence 4, archive 3) failed it. They get that minimum frontmatter in the D-7 migration until gap (f) lands.
- Limit of the shim. The filter drops entries only after doc-kitty's loader has validated them, so an excluded page must still pass the build schema (
relatedmust name published pages. doc-kitty resolves everyrelatedentry against the built pages, and a dangling one is build-fatal. A published page may therefore not list a draft or excluded page there. In the full-corpus spike, 181 such entries had to go; the migration moves them into the body as ordinary links, whichcheck-linksjudges, or drops them.- Gates scope.
dockitty-validate-frontmatteranddockitty-check-linkswalk every file underdocs/, including the 242 files in archive, reports and convergence that are never published. They run over the published sections only; the unpublished trees get only the build-schema minimum above.
D-9: llms.txt is generated (ruling 3)
site/src/pages/llms.txt.ts mounts doc-kitty's llmsTxtRoute. The route takes a title and a description only, so the version-stance block (current docs, runtime model, governance source, mission loop command, where 1.x and 2.x history lives) is inserted by a thin wrapper around its response until doc-kitty takes a preamble option (gap h). The hand-written docs/llms.txt is deleted at cutover.
D-10: the glossary waits on #3024 (ruling 12)
The glossary step starts after #3024 settles the source (packs or docs/context). That step then generates the single .contextive/definitions.yaml doc-kitty reads. doc-kitty reads it from the site project, so it is site/.contextive/definitions.yaml, generated and not committed. Multi-file Contextive (imports:, gap e) is not needed for that. doc-kitty's glossary integration writes generated term pages into <docsDir>/glossary/, which here is docs/glossary/, inside core's tree. Until doc-kitty takes an output path (gap e), that folder is gitignored and excluded from docs_index.py and the page inventory. The current glossary page is a dashboard page (/kitty-specs/glossary.html), outside the baseline, so cutover does not wait for the glossary step.
D-11: a required pull-request build gate (derived)
A docs-site job runs on every pull request that touches docs/**, site/** or its own workflow. It installs with the frozen lockfile and builds with drafts off (D-8). It then runs these gates, each with --astro-config astro.config.mjs (D-1), over the published sections (D-8):
dockitty-validate-frontmatter;dockitty-check-links --strict;dockitty-check-redirect-coverage url-baseline.txt dist --docs ../docs --astro-config astro.config.mjs --declared-redirects redirects.json, plus the interim override of D-5;dockitty-assert-no-broken-links dist --base / --site https://docs.spec-kitty.ai;- a check that
redirects.jsonequals its generated form.
Diagrams. doc-kitty renders PlantUML at build time to static SVG against a self-hosted server, never plantuml.com. Its own CI runs plantuml/plantuml-server as a job-level services: container on localhost:8091 with a readiness wait, sets DK_PLANTUML_SERVER_URL=http://localhost:8091/svg/, and checks with plantuml-guard.mjs (used by assert-build-artifacts.mjs) that no plantuml.com URL and no non-self-hosted default reaches the build. PlantUML only renders in doc-kitty's build mode. Left alone, the mode follows whether a Playwright Chromium happens to be installed, so local and CI builds could differ. The site therefore sets diagrams: true and DK_DIAGRAM_BUILD_RENDER=build, and the job installs a pinned Playwright Chromium (core has 18 Mermaid and 5 PlantUML sources). Core's docs-site job and the deploy workflow adopt doc-kitty's server pattern, with one change: the image is pinned by digest, not :jetty, keeping core's practice of pinning third-party build inputs by digest. The current docker --network=none isolation does not carry over, because the server runs as a service container. A diagram's !include of a remote URL is therefore the remaining egress path, and the diagrams step restricts it in the server's configuration or refuses such includes at source.
The job lives in ci-router.yml, behind a path filter for docs/** and site/** registered in gate_selection.py, and is added to router-gate's needs:. That makes it required, with no continue-on-error. It cannot join ci-aggregate.yml's aggregate-gate: that workflow runs on workflow_run, and GitHub Actions has no cross-workflow needs:. The router's own tests (for example tests/ci/test_docs_lint_job.py) pin the wiring. The deploy workflow builds with the same steps, so main can never deploy a site no pull request built.
D-12: invariants kept (derived)
- Packaging.
site/**is excluded from the sdist; the wheel only ships its declared packages.tests/cross_cutting/packaging/test_packaging_safety.pyasserts it on the built archive.- The sdist's bare
README.mdinclude is gitignore-style. Today it already ships 31docs/**/README.mdand 676kitty-specs/**/README.mdfiles, and the README rename would add 56 more, net. Anchoring it to/README.mdis a pre-existing defect, tracked in #6926, and must land before the rename.
- The sdist's bare
- Fork guard. Every new workflow's automated trigger (push, schedule,
workflow_run) carries the core-repository guard pinned bytests/ci/test_fork_guard.py. Pull-request and dispatch runs work on forks. - Pages probe. The deploy workflow keeps the probe-then-configure job, the 404-skips and other-status-fails semantics, and the
main-only, core-repository-only deploy pinned bytests/release/test_release_ci_ownership.py. The test is updated with the workflow, never loosened.
D-13: parallel run, rollback and DocFX retirement (derived)
A repository has one Pages site, so "parallel" means built in parallel, not served in parallel.
- Parallel run. From the scaffold step on, every pull request and every push to
mainbuilds the doc-kitty site and runs the D-11 gates. The deploy workflow still publishes DocFX. - Canary. The deploy job may only publish from
mainin the core repository, so the canary is a short production window ondocs.spec-kitty.ai. It is announced, and docs merges tomainare frozen for its duration (a push would queue a DocFX deploy in thepagesconcurrency group). Before cutover, one manually dispatched run deploys the doc-kitty build, probes a sample of baseline URLs over HTTP (one.htmlredirect key, one chain, onedir/index.html, onedir/README.html, one directory URL), and redeploys DocFX. This is where the D-5 GitHub Pages result stops being UNVERIFIED. - Cutover. One pull request switches the deploy job's artifact to
site/dist. - Rollback. Revert the cutover pull request. The DocFX path stays intact until retirement, and the README rename waits for retirement (D-1), so the rollback works for that whole window.
- Retirement. After one Spec Kitty release ships with the doc-kitty site live, a pull request removes DocFX:
docfx.json,toc.yml, the template, the .NET setup and the scripts marked "drop" in D-6. It also updates or deletes the 26 Python files that readdocfx.jsonortoc.yml, most of them undertests/docs/, plustests/_support/docfx_reports_guard.pyand the terminology scope helper. The README rename (D-1) follows in its own pull requests.
D-14: upstream gaps go to doc-kitty (derived)
Core does not patch, fork or vendor doc-kitty. A gap is filed upstream; core carries at most a thin, named, consumer-side shim with a removal trigger. The shims are the loader filter (D-8), the llms.txt wrapper (D-9) and the gate override (D-5).
Gaps filed or to file:
- (a) ADR index for date-named, era-foldered ADRs with a frontmatter-status fallback;
- (b) the coverage gate resolves an exact
*.htmlfile before<path>/index.html; - (c) GitHub
> [!NOTE]alerts (7 files use them; the spike rendered them as literal text); - (d) nested persona slugs;
- (e) multi-file Contextive,
imports:, and an output path for generated glossary pages; - (f) excluding sections from the build, before schema validation;
- (g) hub-style card grids for mission and glossary pages, styled with the operator's "chromed" design system, that is doc-kitty's site-chrome theme (
specKittyTheme, the--dk-*tokens); - (h) an
llms.txtpreamble option; - (i) a fixture for a
docsDiroutside the site project.
Consequences
Positive
- One toolkit owns rendering, metadata and gates; seven post-processing steps leave core.
- A pull request builds and gates the site; DocFX's deploy-time-only verification ends.
- No URL in the ledger breaks, and the ledger has a standing home (#2358).
- No .NET toolchain and no credential beyond the workflow token.
Negative
- Node and pnpm join core's CI toolchain for the docs job.
- The frontmatter migration touches most of the 913 Markdown files once (frontmatter only).
- Three consumer-side shims live until their upstream gaps close.
.htmlredirects cost one extra 301 on GitHub Pages.
Neutral
- The kitty-specs dashboards and the dashboard glossary page go dark until the hub-card work (gap g) brings mission pages back.
Confirmation
- The D-11 gate is green on the scaffold pull request with the full ~830-URL ledger.
- The D-13 canary probes pass on the real Pages service.
- After cutover, #2361's post-deploy smoketest and #2362's nightly crawl are green.
Pros and Cons of the Options
Git dependency pinned by commit (chosen)
- Good, because the lockfile is the one pin, local and CI builds match, and no credential is needed.
- Bad, because a bump is a hand-chosen SHA rather than a semver range, and the toolkit's own dependencies resolve in core's lockfile.
CI checkout at a pinned SHA
- Good, because it needs no registry.
- Bad, because the pin lives outside the lockfile and local builds need a second clone.
GitHub Packages
- Good, because it is versioned and integrity-hashed.
- Bad, because it is the separately authorised package the operator steered away from, and installing it locally needs a PAT.
Vendoring
- Good, because it is self-contained.
- Bad, because it forks the toolkit and hides gaps from upstream.
More Information
- Spike evidence, reproduction steps and the GitHub Pages emulator:
kitty-ops/research/01M6947JAAGSMJTM6ST9D429BG/spike-evidence.md. - Delivery. The remaining steps are sub-issues of #6100:
- ledgers and the dual-index folders: #6927 (D-5, absorbs #2358 and #3914) and #6928 (D-1);
- frontmatter: #6929, the build-blocking migration, and #6931, contract completion (D-7);
- site: #6930, the scaffold and
docs-sitejob (D-1, D-2, D-11); #6932, publication (D-8); #6933, redirects (D-5); #6934,llms.txt(D-9); - rendering: #6935, render parity, and #6936, diagrams (D-11);
- rollout: #6937, the required gate; #6938, canary and cutover, with #2361; #6943, DocFX retirement; then #6939, the README rename (D-13);
- after upstream work: #6940, glossary (after #3024); #6941, mission pages (gap g); #6942, ADR index (gap a);
- packaging: #6926 (D-12).
- The upstream doc-kitty issue drafts (a)–(i) sit in the same folder as the spike evidence.
- doc-kitty commit the spike ran against:
494f2d878e2e4766d9859a4f073efb793ad86900(doc-toolkit 0.5.0 plus unreleased commits).