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 indocbook-design.md, which by rule mentions none of this; the CLI is indocbook-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.