# Treating documentation as a build artefact

This manual is compiled, not written into a CMS. One registry holds what may be claimed and with what status; the plan owns every canonical URL; the prose is data rather than code; and a gate refuses the build on a broken link, a thin page, a duplicate search intent or a capability described as working when the registry says it is not.

- **Status:** Available
- **Audience:** both, developer
- **Last verified:** 2026-09-10
- **Canonical:** https://connectbyjbrh.com/research/documentation-as-a-build/

## One registry decides what may be claimed

`docs-source/facts.py` is the only place a capability's status is recorded, and it records the evidence alongside it — the module or script that proves the claim. A page whose subject appears there does not get to choose a friendlier word. A page whose subject does not appear there has to say the nearest honest thing and say that it is doing so.

The same file carries the numbers a page is allowed to quote, the canonical name for each thing, the names that have been retired, the protocol version strings the generated machine descriptions carry, and a written list of claims that may never be made. Putting all of that in one importable module rather than in a style guide is what makes the rules checkable rather than merely stated.

> **Note** The plan owns `id`, `kind`, `url` and `title`; a content module that sets any of them is refused. Two places deciding a canonical URL is how a corpus ends up with the same page at two addresses and a sitemap that disagrees with both.

## Many outputs, one source

| Output | Who reads it | What it must never contradict |
|---|---|---|
| Rendered HTML under `webapp/docs/` | A person, and a crawler | The registry's status for the capability it describes |
| Markdown alternates | A retrieval system that prefers plain text | The HTML — same words, different envelope |
| Documentation manifests under `webapp/docs-data/` | A machine enumerating the corpus | The set of pages that actually exist |
| `llms.txt` | An agent looking for a map | The public surface only — no private path may appear in it |
| The OpenAPI, Arazzo and AsyncAPI descriptions | A developer or a code generator | The protocol versions recorded in the registry |
| Sitemaps and the change feed | Search and AI crawlers | The published page set, in both directions |

Generating all of them from one source is not tidiness. It removes the failure where a capability is corrected on the page and left standing in the manifest, which is the version a machine reads and a person never sees.

## The gate, and what each check is for

| Check | Catches |
|---|---|
| Plan-to-content join | A planned page nobody wrote, and prose for a page nobody planned — there is no default body, so an unwritten page is an error rather than an empty page that quietly ships |
| Shape validation | A missing part, a page under the 420-word floor, an answer paragraph outside 22–95 words, fewer than three sections or two questions |
| Duplicate detection | The same canonical URL, the same page id, or two pages claiming one primary query |
| Link graph | A link to a page that does not exist, and a page nothing links to |
| Language | Machine-writing phrases, adjective tics past their budget, and one term taking more than 2.2% of a page it is not about |
| Honesty | A retired product name, a claim on the forbidden list, and a capability the registry calls foundation or not-yet described as working |
| Leakage | Anything shaped like a key, a token, a real address or a real telephone number |
| Kind rules | A technology page that never answers whether Connect uses the technology; a workflow page with no stage chain |

It fails closed, and its exit status is the number of distinct failure classes rather than the number of problems, so a hundred instances of one mistake read as one mistake. The corpus-wide checks are the ones a single page cannot perform on itself: nothing about one page reveals that another page already claims its query.

## Counting is measured, never reported

Progress is measured by importing each content module and counting the dictionary it exports, because that is what the build itself does. A writer's account of its own work is not evidence: the first batch to report twenty pages finished had written six, and an edit that replaced instead of appending shows up in the count and nowhere else.

- The count is of pages that **import**. A module with a syntax error counts zero, loudly, rather than counting what was intended.
- A single-module run imports only that module, so one writer's half-saved file cannot fail another writer's check.
- Template detection is deliberately corpus-wide and generous: it refuses an opening formula shared by more than twenty pages, a section heading shared by more than thirty, and a corpus where over a third of pages contain nothing but paragraphs.

## What a build cannot check

Every check above is a check of form. A page can pass all of them and still be wrong, because no gate can read a sentence and know whether it is true. What the machinery buys is that a *false* claim has to be written deliberately rather than arrived at by accident: the status has to be overridden, the sources line has to name a file, and the claim has to survive a list of things that may never be said.

- Freshness is dated, not verified. Every page carries the date its facts were read; nothing re-reads them.
- A source line is a convention. The gate can see that sources exist; it cannot see whether the writer opened them.
- Accessibility, contrast and rendering are not covered here at all — see [A focus ring that computed to nothing](/research/focus-rings-that-computed-to-none/).

## Questions

### Why is the prose data rather than code?

Because a page written as a dictionary cannot change how pages render. A cluster of pages is then reviewable as content, a diff shows the page a change belongs to, and no writer can accidentally alter the template for everybody else while editing a paragraph.

### Why does the gate fail closed rather than warn?

Because a warning in a build that ships anyway is a list nobody reads. The specific fear is an overclaim reaching the public site: a page describing something as working when the registry says the customer-facing capability is narrower is the failure this whole arrangement exists to prevent.

### Are code samples held to the same language rules?

No, and deliberately. Code blocks are excluded from the word count and from the phrase scan, so a configuration sample that happens to contain one of the refused words is not treated as bad writing. Everything a person reads as prose is scanned.

## Related

- [The machine-readable documentation](https://connectbyjbrh.com/developers/machine-manifests/)
- [Writing documentation that both a person and a retrieval system can use](https://connectbyjbrh.com/research/writing-for-people-and-machines/)
- [A four-word vocabulary for capability status](https://connectbyjbrh.com/research/status-vocabulary/)
- [End-to-end workflows](https://connectbyjbrh.com/docs/workflows/)
- [Five ways a test suite has passed while proving nothing](https://connectbyjbrh.com/research/false-passing-tests/)

## What this page is based on

- `docs-source/facts.py` — the capability registry, status vocabulary, measured numbers, canonical and retired names, protocol versions
- `docs-source/schema.py` — `validate()`, the word floor, `BANNED_PHRASES`, `WORD_BUDGET` and the answer-length window
- `tools/docs_check.py` and `tools/docs_progress.py` — the closed gate and the measured count
- `docs-source/content/__init__.py` — `load()`, and the fields the plan owns
