# The docbook pipeline — reasoning
> **Class:** `reasoning`. This file holds the *why*, the trade-offs, and **every design
> we rejected**. The descriptive shape we actually run is in
> [`docbook-design.md`](docbook-design.md), which by rule mentions none of this; the CLI
> is in [`docbook-guide.md`](docbook-guide.md). If you want "why not X?" — X is here.
## Why one typst engine, and no JS / no Mermaid (NUKE-MERMAID)
The whole pipeline compiles through a single embedded typst engine — one dependency, no
shellout, no browser, no JavaScript at render time. The payoff is that docs render
identically everywhere: in CI, on an airgapped box, and inside a PDF, with no diagram
daemon to install. Determinism and offline builds fall out of it.
**Rejected: Mermaid (and any JS/HTML diagram engine).** Mermaid needs a JS runtime to
turn text into a picture, so the diagram only exists after a browser (or a headless
Node) has run — a PDF or an airgapped git host shows raw code, not a graph. It also
drifts: the `depgraph` must match `cargo metadata`, and a hand-or-JS-rendered graph
can't be regenerated in-process. So diagrams are drawn in Rust/typst primitives
(`svg::render_graph_svg`, native-typst bar charts) and the SVG is *committed*, so it
resolves in the Codeberg/GitHub web UI with no build step. This is a standing rule —
NUKE-MERMAID — and it is why a Graphviz-revert branch was treated as a regression, not
a feature. The one cost we accept: our diagrams are simpler than Mermaid's, hand-drawn
per subsystem rather than auto-laid-out from arbitrary graph text.
**Rejected: a hand-maintained assets tree at the repo root.** A top-level `assets/`
would duplicate media and split the namespace. Git hosts resolve `.nornir/assets/x.svg`
from a repo-root README just as well, so all generated + committed media lives under one
nornir-owned `.nornir/assets/` namespace — no web-UI reason to pollute the root.
## Why three books split by audience (not one `book.pdf`)
**Rejected: a single `book.pdf`.** The original pipeline rendered every source into one
undifferentiated document. It grew a diary problem: the manual a newcomer reads was
padded with rationale, rejected alternatives, changelog-style history, and half-decided
notes. A reader looking for "what it is and how to use it" waded through "why we didn't
do B."
So the set splits by *audience* into three PDFs — `manual` (README + design + guide +
benchmarks: the crisp 10-minute core), `idiot_guide` (every zero-knowledge step), and
`unsorted` (reasoning + history + the holding pen). Together they are the whole set;
nothing is thrown away, but the manual stays thin because the diary moved to
`unsorted.pdf`. `build_book` (the single-document path) still exists for a whole-set
render, but `build_books` is the audience-split default.
**Why routing is data, not code.** The `[book.*]` → `classes` map lives in
`.nornir/docs.toml`, and `book.rs` is written as a *standalone* renderer that takes a
routing config plus classed sources. The alternative — hard-coding "manual = these
files" in Rust — would make the split nornir-specific and force a recompile to add a
book. As data, the same library renders any classed doc set, inside nornir or out, and a
repo can define its own books.
## Why classes route by filename suffix
A chapter's class (which decides its book *and* what may be written in it) had to come
from somewhere. **Rejected: a hand-maintained manifest** ("these files are design, those
are guide") — it drifts the instant someone adds a file and forgets the manifest, and it
duplicates information the filename already implies. **Rejected: front-matter only** — it
buries the routing key inside the file where a directory listing can't see it.
So the *filename suffix* is the routing key: `-design.md`, `-guide.md`, `-reasoning.md`,
`-for-idiots.md`, … `classify` reads it with zero config, so `ls .nornir/` tells you the
book layout at a glance. An explicit `<!-- nornir:class: X -->` override still exists for
the rare file whose name can't carry the class — but it is the exception, not the rule.
A file whose suffix names no class falls to `"other"` and is *omitted and logged*, never
silently dropped, so a mis-named file is loud, not lost.
**Why the strictness law (design/guide contain no *why*).** This is the mechanism that
keeps the manual thin. A `design` section states *only the design that was chosen*, as
if it were the only option — no "we picked A over B", no "alternatives were considered".
The reason: a design section that argues for itself is a design section that grows long,
and its argument is exactly what a *different* reader (the "why?" reader) wants in a
*different* place. So the reasoning is never deleted — it moves here, to `reasoning`,
which a descriptive section may link but must not inline. Chosen design → `design`
(short); rejected design + the argument → `reasoning`. Keeping both, but apart, is the
whole trick.
## Why markers (generated tables in hand-edited prose)
The tables that go stale — benchmark numbers, test counts, coverage, the dependency
graph — are exactly the ones a human copies wrong. We saw stale znippy/holger test
counts and hand-maintained "Benchmark history" tables that lied.
**Rejected: fully generated README files.** Generating the whole document from a template
loses the hand-written pitch and prose voice. **Rejected: hand-maintained tables** — they
drift the moment the code changes. So the marker is the seam: prose is hand-authored, and
a `<!-- nornir:gen:start:X -->` region is a hole the renderer refills from the *source of
truth* (the warehouse, `cargo metadata`, `syn`, DWARF) on every render. The dispatcher
re-emits args **sorted** so the output is deterministic regardless of how they were typed
— which is what lets `docs check` be a meaningful drift gate rather than noise.
**Why the render is whole-file, not just the marker regions.** nornir rewrites the entire
artifact, not only the marked spans. The alternative (surgical in-place edits) means the
non-marker prose in the *output* can drift from the `.nornir/` source undetected. Rewriting
the whole file makes the source authoritative — which is also why the artifact gets a loud
GENERATED header and a `chmod -w` bit: any edit to the output is a mistake by construction.
## Why the docs index is separate from the code index
**Rejected: one Tantivy index for code and docs.** They answer different questions and
index different text: the code index indexes `.nornir/*.md` templates as *source* (markers
unfilled), while a doc reader wants the *generated* docs (markers filled, the real numbers).
Mixing them would return template noise for a docs query. So docs get their own index over
the generated docs only, snapshotted into its own Iceberg tables so it time-travels
(`docs search --sha`) exactly like the code index — same machinery, separate home.
## Why freshness splits by cost (hook vs release gate)
Keeping generated docs fresh could be one mechanism, but the two costs are very different.
The README markdown is cheap and deterministic; the SVG charts + depgraph + book PDF are
heavy and non-deterministic. So they split: the **pre-commit hook** refreshes markdown
only, on every commit, and — deliberately — **exits 0 on any failure** so a missing nornir
or unreachable warehouse never blocks an unrelated commit (freshness is a convenience
there). The **release gate** is the hard assertion: it renders + stages everything
(including SVG/PDF) and *then* `check_all` fails the release on any residual drift. Making
the hook non-blocking is a choice — the hook is best-effort, the gate is authoritative.
## How we got here (the road, briefly)
The pipeline started as a single `assemble_and_check` flow with a whole-file
`<!-- nornir:generated -->` marker (still present, `mod.rs`, for repos not yet on the
`.nornir/` layout). It grew the `.nornir/` layout (sources separated from outputs), then
the marker section renderers, then `docs-export` (typst) for PDF/HTML, then `build_book`
(one book), and finally `build_books` (the audience split) once the single book's diary
problem became acute. The self-render fix (`[repo.<name>].path` override) is the scar
worth remembering: before the `Repo` struct carried a `path` field, `docs book nornir`
resolved to the wrong checkout and wrote an empty ~16 KB `book.pdf`; now a path override
points the self-render at the configured worktree, regression-guarded by
`self_repo_book_lands_at_real_docs_path_not_tmp`.