", ...] -> strict xfail on that leg
+env = {} # env vars set for the leg via monkeypatch
+
+# ───────────────────────────── start here ─────────────────────────────
+
+[story.tools]
+
+[story.prompts]
+
+[story.resources]
+
+[story.lifespan]
+
+[story.dual_era]
+era = "dual-in-body"
+multi_connection = true
+
+[story.streaming]
+
+[story.mrtr]
+era = "modern"
+
+[story.legacy_elicitation]
+era = "legacy"
+status = "legacy"
+
+[story.refund_desk]
+# Resolver elicitation picks its transport per era: input_required round-trips on
+# the modern leg, push elicitation (ctx.elicit) on the legacy one.
+lowlevel = false
+
+[story.sampling]
+era = "legacy"
+status = "deprecated"
+
+[story.stickynotes]
+
+[story.custom_methods]
+lowlevel = false
+
+[story.apps]
+# Extension API is MCPServer-tier (Apps decorators + extensions=[...]); no lowlevel variant.
+# The extensions capability map (SEP-2133) rides server/discover, a modern-only path, so
+# `main` pins "auto" (legacy initialize cannot carry it) and the leg is http-asgi.
+lowlevel = false
+transports = ["in-memory", "http-asgi"]
+era = "dual-in-body"
+
+[story.extensions]
+# Same constraints as `apps`: MCPServer-tier extension API, capability map rides
+# server/discover (modern only), client guards the capability assert by presence.
+lowlevel = false
+transports = ["in-memory", "http-asgi"]
+era = "dual-in-body"
+
+[story.subscriptions]
+# subscriptions/listen exists only on the 2026 wire, so there is no legacy leg.
+# The listen request parks for the stream's lifetime; the client ends it by
+# cancelling the awaiting scope (the spec's client-side close).
+era = "modern"
+
+[story.schema_validators]
+
+[story.middleware]
+# Lowlevel-only: `Server.middleware` is the one public hook (no MCPServer accessor yet).
+lowlevel = false
+
+[story.parallel_calls]
+# A per-client fresh target over a real ASGI transport is harness machinery, not user
+# code; the same client body works unchanged over HTTP.
+transports = ["in-memory"]
+multi_connection = true
+
+[story.roots]
+era = "legacy"
+status = "deprecated"
+
+[story.pagination]
+
+[story.error_handling]
+
+[story.serve_one]
+# Lowlevel-only: the kernel drivers take a `lowlevel.Server`; `MCPServer` has no public
+# accessor for its underlying one yet, so there is no MCPServer-tier variant to show.
+transports = ["in-memory"]
+lowlevel = false
+
+[story.stateless_legacy]
+transports = ["http-asgi"]
+server_export = "app"
+era = "dual-in-body"
+multi_connection = true
+
+[story.json_response]
+transports = ["http-asgi"]
+server_export = "app"
+era = "modern"
+needs_http = true
+
+[story.legacy_routing]
+transports = ["http-asgi"]
+server_export = "app"
+era = "dual-in-body"
+multi_connection = true
+
+[story.starlette_mount]
+transports = ["http-asgi"]
+server_export = "app"
+lowlevel = false
+mcp_path = "/api/"
+
+[story.sse_polling]
+transports = ["http-asgi"]
+server_export = "app"
+era = "legacy"
+status = "legacy"
+timeout_s = 20
+# event_store.py is local; example-grade only (sequential IDs, no eviction).
+
+[story.standalone_get]
+transports = ["http-asgi"]
+era = "legacy"
+status = "legacy"
+
+[story.reconnect]
+transports = ["http-asgi"]
+# Both connection modes are pinned inside main itself ("auto" to populate the discover
+# cache, then a hard pin + prior_discover=); the leg hands it the real-user default.
+era = "dual-in-body"
+multi_connection = true
+
+[story.bearer_auth]
+transports = ["http-asgi"]
+server_export = "app"
+fixed_port = 8000 # issuer/PRM metadata bake in :8000
+
+[story.oauth]
+transports = ["http-asgi"]
+server_export = "app"
+multi_connection = true
+fixed_port = 8000 # issuer/PRM metadata bake in :8000
+env = { OAUTH_DEMO_AUTO_CONSENT = "1" }
+
+[story.oauth_client_credentials]
+transports = ["http-asgi"]
+server_export = "app"
+fixed_port = 8000 # issuer/PRM metadata bake in :8000
+
+[story.identity_assertion]
+transports = ["http-asgi"]
+server_export = "app"
+fixed_port = 8000 # issuer/PRM metadata bake in :8000
+
+# ───────────────────────────── deferred ─────────────────────────────
+# README-only placeholders; no client.py, not expanded into legs.
+# test_manifest_matches_filesystem checks these match the README-only dirs.
+
+[deferred]
+caching = "client honouring + per-result override unlanded"
+tasks = "SEP-2663 — tasks extension runtime (server-decided augmentation, CreateTaskResult)"
+skills = "#2896 — SEP-2640"
+events = "#2901 + #2896"
diff --git a/scripts/docs/build 2.sh b/scripts/docs/build 2.sh
new file mode 100755
index 0000000000..8f545761bc
--- /dev/null
+++ b/scripts/docs/build 2.sh
@@ -0,0 +1,46 @@
+#!/usr/bin/env bash
+#
+# Build the v2 documentation site for this checkout into `site/`.
+#
+# Zensical runs no MkDocs plugins or hooks, so the build is three steps:
+# materialise the API reference pages and the concrete config, build the
+# site strictly (plus the order-independence and cross-reference checks
+# Zensical doesn't do itself), then generate llms.txt and the per-page
+# markdown renditions. This script is the single owner of that recipe, dependency
+# sync included — CI (shared.yml, docs-preview.yml) and scripts/build-docs.sh
+# all call it. The toolchain detection in docs-preview.yml and build-docs.sh
+# keys on this file's path and expects the site under site/.
+#
+# Usage:
+# scripts/docs/build.sh
+#
+set -euo pipefail
+
+SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
+# Snippet includes (`--8<--`) resolve against the working directory, which
+# must therefore be the repo root.
+cd "$SCRIPT_DIR/../.."
+
+uv sync --frozen --group docs
+
+# Zensical's incremental cache is unsound: a warm rebuild where only some
+# pages re-render silently drops cross-references to cache-hit pages, and
+# HTML for since-deleted pages lingers in site/. Build cold so the output
+# (and the checks below) are deterministic.
+rm -rf .cache site
+
+uv run --frozen --no-sync python scripts/docs/build_config.py
+uv run --frozen --no-sync zensical build -f mkdocs.gen.yml --strict
+
+# The build above renders pages in one arbitrary (filesystem-dependent)
+# order; prove the API reference renders in hostile orders too — see the
+# check's docstring for the failure mode this guards.
+uv run --frozen --no-sync python scripts/docs/check_render_order.py
+
+# Zensical stays green even under --strict when a cross-reference fails to
+# resolve (rendered as literal bracket text) or an objects.inv inventory
+# fails to download (every link through it silently degrades to plain text);
+# MkDocs strict mode aborted on both. Validate the built site instead.
+uv run --frozen --no-sync python scripts/docs/check_crossrefs.py --site-dir site
+
+uv run --frozen --no-sync python scripts/docs/llms_txt.py --site-dir site
diff --git a/scripts/docs/build_config 2.py b/scripts/docs/build_config 2.py
new file mode 100644
index 0000000000..daba648344
--- /dev/null
+++ b/scripts/docs/build_config 2.py
@@ -0,0 +1,90 @@
+"""Produce the concrete Zensical build config from `mkdocs.yml`.
+
+Zensical builds from `mkdocs.yml` directly, but it has no equivalent of
+mkdocs-literate-nav: the "API Reference" navigation has to be materialised
+as explicit entries. This script regenerates the `docs/api/` tree (via
+gen_ref_pages) and writes `mkdocs.gen.yml` with the real API nav spliced
+in — that generated file is what `zensical build`/`serve` consumes.
+
+Usage:
+ python scripts/docs/build_config.py
+"""
+
+from __future__ import annotations
+
+import posixpath
+import re
+from pathlib import Path
+
+# Both scripts live in this directory, which Python puts on sys.path[0] when
+# `build_config.py` is run directly (its documented invocation).
+import gen_ref_pages
+import yaml
+
+ROOT = Path(__file__).parent.parent.parent
+
+# A scheme-prefixed nav value (https:, mailto:, ...) is an external link, not
+# a page path (same classifier as llms_txt.py; a `://` test would misread
+# scheme-only URIs as pages).
+_EXTERNAL = re.compile(r"[a-zA-Z][a-zA-Z0-9+.-]*:")
+
+
+def _nav_pages(nav: list) -> set[str]:
+ """Collect every local page reference in the nav (external links excluded)."""
+ pages: set[str] = set()
+ for entry in nav:
+ value = next(iter(entry.values())) if isinstance(entry, dict) else entry
+ if isinstance(value, list):
+ pages |= _nav_pages(value)
+ elif not _EXTERNAL.match(value):
+ pages.add(value)
+ return pages
+
+
+def _validate_nav(nav: list, docs_dir: Path) -> None:
+ """Fail on nav/page drift in either direction.
+
+ Zensical (0.0.48) ships a nav entry for a nonexistent page as a broken
+ link without any diagnostic even under --strict, and publishes a page
+ that no nav entry reaches as unreachable orphan HTML; MkDocs aborted the
+ build on both (--strict with `validation.omitted_files: warn`).
+ Validating here keeps those guarantees. The generated `api/` tree is
+ exempt from the orphan check: its nav is spliced in from the same
+ generator that writes the files, so it cannot drift.
+ """
+ pages = _nav_pages(nav)
+ # Containment before existence: `docs_dir / page` would happily resolve
+ # an absolute value or a `../` escape against the wrong root.
+ if escaping := sorted(p for p in pages if p.startswith("/") or posixpath.normpath(p).startswith("..")):
+ raise SystemExit(f"build_config: nav references pages outside docs/: {escaping}")
+ if missing := sorted(page for page in pages if not (docs_dir / page).is_file()):
+ raise SystemExit(f"build_config: nav references pages that don't exist under docs/: {missing}")
+ # Dot-directories (e.g. `.overrides` theme files) are not pages: the site
+ # builder ignores them, so the orphan check must too.
+ relative = (page.relative_to(docs_dir) for page in docs_dir.rglob("*.md"))
+ on_disk = {page.as_posix() for page in relative if not any(part.startswith(".") for part in page.parts)}
+ if orphaned := sorted(page for page in on_disk - pages if not page.startswith("api/")):
+ raise SystemExit(f"build_config: pages under docs/ that no nav entry reaches: {orphaned}")
+
+
+def build_config() -> None:
+ config = yaml.safe_load((ROOT / "mkdocs.yml").read_text(encoding="utf-8"))
+
+ api_nav = gen_ref_pages.generate()
+ if not api_nav:
+ raise SystemExit("build_config: gen_ref_pages produced no API pages — did the src/ layout move?")
+ for entry in config["nav"]:
+ if isinstance(entry, dict) and "API Reference" in entry:
+ entry["API Reference"] = api_nav
+ break
+ else:
+ raise SystemExit("build_config: no 'API Reference' entry found in mkdocs.yml nav")
+
+ _validate_nav(config["nav"], ROOT / "docs")
+
+ output = ROOT / "mkdocs.gen.yml"
+ output.write_text(yaml.safe_dump(config, sort_keys=False, allow_unicode=True), encoding="utf-8")
+
+
+if __name__ == "__main__":
+ build_config()
diff --git a/scripts/docs/check_crossrefs 2.py b/scripts/docs/check_crossrefs 2.py
new file mode 100644
index 0000000000..39f866a00f
--- /dev/null
+++ b/scripts/docs/check_crossrefs 2.py
@@ -0,0 +1,170 @@
+"""Fail the docs build when a page's cross-references did not resolve.
+
+Zensical (0.0.48) stays green even under `--strict` on two failure modes
+MkDocs strict mode aborted on:
+
+- An unresolvable `[text][identifier]` cross-reference renders as literal
+ bracket text (mkdocs-autorefs used to warn). The generated API index and
+ the docstring cross-references rely on such references resolving.
+- A failed `objects.inv` inventory download is logged as an ERROR record and
+ otherwise ignored, silently degrading every link through that inventory
+ (thousands of standard-library links alone) to plain text.
+
+Both are caught from the built site itself, so no log-wording change can
+disarm the check: an unresolved reference leaves a tell-tale bracket
+sequence in prose text (code blocks legitimately contain `][`, e.g. dict
+indexing, so only text outside ``/`` counts), and every inventory
+declared in `mkdocs.yml` must contribute at least one resolved reference —
+an `autorefs-external` anchor, which hand-authored prose links to the same
+host never carry — to the site (an inventory that contributes none is dead
+config and fails too).
+
+Offline contributors can skip the inventory check by setting
+`DOCS_ALLOW_INVENTORY_FAILURE=1`; CI (`CI=true`) never skips it.
+
+Usage:
+ python scripts/docs/check_crossrefs.py --site-dir site
+"""
+
+from __future__ import annotations
+
+import argparse
+import os
+import re
+import sys
+from html.parser import HTMLParser
+from pathlib import Path
+from urllib.parse import urlsplit
+
+import yaml
+
+ROOT = Path(__file__).parent.parent.parent
+
+# Unresolved cross-reference tell-tales in extracted prose (`\x00` marks a
+# skipped code element, see _ProseTextExtractor): the two-part
+# `[text][identifier]` reconstruction — the identifier part is always plain
+# text, so a code mark inside the second brackets means indexing prose like
+# `data[`x`][`y`]`, not a reference — and the shortcut `[`identifier`]` form,
+# which extracts as `[\x00]` unless a preceding word character or bracket
+# makes it a subscript like `list[`str`]`.
+_UNRESOLVED = re.compile(r"\]\[[^\]\s\x00]*\]|(?]*autorefs-external[^>]*>")
+
+
+class _ProseTextExtractor(HTMLParser):
+ """Collect text outside //