Documentation quality collapses when a model drafts sentences that no later test can falsify. The useful split is not AI pages versus human pages, but projection versus promise inside one tree. A projection is any document that another artifact already determines, including OpenAPI, CLI schemas, and commit subjects. A promise is any document that binds callers to behavior a compiler cannot observe, including migration duty and support hours.
This article proposes a source-coupled documentation graph, an RFC 2119 scanner, and a generation job limited to projections. The workflow is a labeled proposal that includes Python a team can run locally against fixture files. It does not claim production metrics, model rankings, or hardware details that nobody supplied for this account.
Why projection pages and promise pages diverge
Generated reference text usually restates types, paths, and flags that already live in a schema. Those pages go stale when authors rewrite them by hand while the schema keeps moving underneath. Promise pages fail in the opposite direction, because fluent paragraphs can invent a guarantee the product never funded. Cheap generation increases both failure modes at the same time, which is why a graph in CI is worth more than another style-guide paragraph.
A model can emit a complete tutorial that silently upgrades a beta flag into a supported public contract. A second pass can add MUST language to a generated reference because the surrounding guide already uses that voice. RFC 2119 defines those keywords, and RFC 8174 clarifies that only the uppercase forms carry normative weight. The checks below look for those uppercase tokens plus a short list of guarantee phrases that appear when promises leak into projections.
The graph schema
Keep one file at docs/graph.yaml that lists every published path the site generator will ship. Each node declares a source artifact, a role of projection or promise, and whether a model may rewrite the file at all. A projection without a readable source is a schema error, not a drafting opportunity for any model. A promise node with model_rewrite set to anything except forbidden is also a schema error.
# docs/graph.yaml
# Proposal: inventory only. Paths are examples, not a live product tree.
version: 1
nodes:
- path: docs/reference/http.md
role: projection
source:
kind: openapi
file: spec/openapi.yaml
model_rewrite: descriptions_only
public: true
- path: docs/reference/cli.md
role: projection
source:
kind: cli_help
command: ["python", "-m", "myapp", "--help"]
model_rewrite: format_tables
public: true
- path: docs/changelog.md
role: projection
source:
kind: conventional_commits
range: "origin/main..HEAD"
model_rewrite: summarize_subjects
public: true
- path: docs/migration.md
role: promise
source:
kind: none
model_rewrite: forbidden
public: true
- path: docs/support.md
role: promise
source:
kind: none
model_rewrite: forbidden
public: true
Load that file in the same job that builds the site, not in a separate weekly cleanup script. Weekly cleanup scripts rot, while a build-time load fails the pull request that introduced the unknown page. Treat an unpublished path as a test fixture only when its name starts with _ and the graph records that exception explicitly.
Step 1 — Inventory every published page by source coupling
- List every Markdown file that the site generator publishes, including files that already sit under a generated folder.
- Mark a file as
projectiononly when a command or schema can rebuild its factual skeleton without guesswork from the model. - Mark a file as
promisewhen the page answers what the product owes callers rather than what the binary exposes today. - Refuse to publish any file that is missing from the graph, so a silent page cannot bypass the gate during review.
The inventory is tedious on the first pass and cheap on every pass after that. Most repositories discover that tutorials mix both roles in a single file, which is why reviews feel endless. Split mixed files before enabling any model rewrite, because one model_rewrite flag cannot describe a hybrid page honestly. Until that split exists, keep the file in promise and leave the model queue empty for it.
Step 2 — Compile projection pages from the real source
The following Python is a local, unexecuted example of a compiler front-end for HTTP reference pages. It reads OpenAPI path summaries and writes a skeleton whose headings are a pure function of spec/openapi.yaml. A model, if used at all, may only rephrase description and summary fields that already exist in that spec.
# scripts/compile_http_reference.py
# Proposal / unexecuted example: adapt paths to the local spec.
from __future__ import annotations
import pathlib
import sys
try:
import yaml
except ImportError:
sys.stderr.write("install pyyaml before running this compiler\n")
raise
SPEC = pathlib.Path("spec/openapi.yaml")
OUT = pathlib.Path("docs/reference/http.md")
def load_spec() -> dict:
with SPEC.open(encoding="utf-8") as handle:
data = yaml.safe_load(handle)
if not isinstance(data, dict) or "paths" not in data:
raise SystemExit("openapi.yaml must contain a paths object")
return data
def render(spec: dict) -> str:
lines = [
"<!-- generated: do not edit. source: spec/openapi.yaml -->",
"# HTTP reference",
"",
"This page is a projection of the OpenAPI document.",
"Normative compatibility rules live in docs/migration.md.",
"",
]
paths = spec.get("paths") or {}
for path in sorted(paths):
item = paths[path] or {}
lines.append(f"## `{path}`")
lines.append("")
for method in ("get", "post", "put", "patch", "delete"):
op = item.get(method)
if not op:
continue
summary = (op.get("summary") or "").strip()
desc = (op.get("description") or "").strip()
lines.append(f"### {method.upper()}")
lines.append("")
if summary:
lines.append(summary)
lines.append("")
if desc:
lines.append(desc)
lines.append("")
else:
lines.append("_No description in spec; left blank on purpose._")
lines.append("")
return "\n".join(lines) + "\n"
def main() -> None:
OUT.parent.mkdir(parents=True, exist_ok=True)
OUT.write_text(render(load_spec()), encoding="utf-8")
if __name__ == "__main__":
main()
Run the compiler before any language-model pass, then inspect the diff as if it were generated code.
python scripts/compile_http_reference.py
git diff -- docs/reference/http.md
If the diff contains a path that is not in the spec, the compiler is wrong or an editor bypassed the generated header. Either case is a process bug, not a writing problem for the documentation team. Restore the file from the compiler output and move any extra prose into docs/migration.md or into the OpenAPI description field, depending on whether the sentence is a promise or a projection.
A CLI reference follows the same compile rule with a different extractor. Capture --help in CI, parse flags into a table, and refuse flags that the binary did not print. Do not let a model invent a hidden flag because a similar product exposes one, since that sentence becomes a support contract the binary cannot keep.
Step 3 — Allow a model only on description glue
A free model is useful when OpenAPI descriptions are technically correct and stylistically uneven across operations. The allowed transformation is narrow: take an existing description string and emit Markdown that does not add endpoints, status codes, or guarantees. MonkeyCode's free model access and free server option can host that rewrite job without placing promise files in the same queue. Disclosure: This article was prepared as part of MonkeyCode's product outreach.
The job should receive the spec excerpt and the compiled skeleton, then return a unified diff that CI re-compiles and checks against the skeleton heading set. Heading drift means the model invented structure, which this workflow treats as a failed compile rather than as a wording nit. Numeric SLAs, retry counts, and availability percentages are also compile failures when they appear on a projection page.
A proposed prompt envelope, not a vendor-specific model name, looks like the block below.
You receive an OpenAPI operation object and the compiled Markdown section.
Rewrite only the prose under the existing headings.
Do not add paths, methods, status codes, rate limits, or RFC 2119 words.
If the spec omits a description, keep the blank placeholder.
Return a unified diff against docs/reference/http.md and nothing else.
Store the envelope next to the graph so reviewers can see the brief without opening a chat transcript. Rotate the envelope when the scanner gains new leak phrases, because the model will otherwise keep smuggling the old promises in new synonyms. Keep changelog rewrites on a separate envelope that allows past-tense verbs and forbids second-person instructions such as "you must rotate keys."
Step 4 — Scan projection pages for smuggled promises
Uppercase RFC 2119 keywords are a practical signal that a projection has started to legislate product behavior. English hedges such as "we will always" and "guaranteed" belong to the same leak class even when they avoid those keywords. The scanner below fails CI when those tokens appear outside nodes whose role is promise.
# scripts/scan_normative.py
# Proposal / unexecuted example.
from __future__ import annotations
import pathlib
import re
import sys
import yaml
GRAPH = pathlib.Path("docs/graph.yaml")
TOKEN = re.compile(
r"\b(MUST|SHALL|SHOULD|MAY|REQUIRED|guaranteed|always support|SLA)\b",
re.IGNORECASE,
)
UPPER = re.compile(r"\b(MUST|SHALL|SHOULD|MAY|REQUIRED)\b")
def main() -> int:
graph = yaml.safe_load(GRAPH.read_text(encoding="utf-8"))
failed = 0
seen = set()
for node in graph["nodes"]:
path = pathlib.Path(node["path"])
seen.add(path)
if node["role"] == "projection" and node["source"]["kind"] == "none":
sys.stderr.write(f"{path}: projection missing source\n")
failed += 1
if node["role"] == "promise" and node.get("model_rewrite") != "forbidden":
sys.stderr.write(f"{path}: promise must forbid model rewrite\n")
failed += 1
if not path.is_file():
sys.stderr.write(f"missing file: {path}\n")
failed += 1
continue
if node["role"] != "projection":
continue
text = path.read_text(encoding="utf-8")
for i, line in enumerate(text.splitlines(), 1):
if TOKEN.search(line) or UPPER.search(line):
sys.stderr.write(
f"{path}:{i}: normative leak: {line.strip()}\n"
)
failed += 1
return 1 if failed else 0
if __name__ == "__main__":
raise SystemExit(main())
python scripts/scan_normative.py
make -n docs-projections docs-scan
A changelog projection should summarize commit subjects in past tense without instructing operators to rotate keys or freeze deploys. Those sentences belong in docs/migration.md, which the graph marks as a promise and which the model queue never receives. If a breaking commit lands without a matching migration section, fail the release tag rather than asking the model to invent caller actions from the commit message.
Wire both tools into the same target the site already uses, so documentation cannot ship when either the compiler or the scanner is skipped.
# Makefile excerpt — proposal / unexecuted example
.PHONY: docs-projections docs-scan docs
docs-projections:
python scripts/compile_http_reference.py
docs-scan:
python scripts/scan_normative.py
docs: docs-projections docs-scan
Step 5 — Keep human authorship on the promise side
Humans own every sentence that would still be true if the repository were deleted and rewritten from a new skeleton. Compatibility windows, data-residency statements, and "we will not log request bodies" sit in that set because no OpenAPI compiler can see funding or legal review. The migration guide may quote generated changelog bullets, but the required caller action must be written by someone who can delay a release.
A minimal promise file still needs structure so reviews do not dissolve into unbounded prose. The stub below is a proposal, not a required schema for every product line.
# Migration
## Breaking change
- id: 2026-09-http-trailing-slash
changelog_ref: docs/changelog.md#unreleased
caller_action: |
Send the canonical path without a trailing slash.
Clients that relied on a redirect must stop before 2026-10-01.
owner: api-oncall
signed: pending
CI should fail a release tag when any signed: pending remains under a breaking changelog section. That gate is a product control, not a writing-style preference for documentation tone. Models are the wrong signer because they cannot accept pager duty when the promise is later false. Record the signer as a team alias that already owns the API, not as a generic docs user that nobody pages.
Decision table
| Source of truth present | Role | Model may draft | Human must own |
|---|---|---|---|
| OpenAPI or JSON Schema | projection | Rephrase existing descriptions | The schema and error taxonomy |
CLI --help or equivalent dump |
projection | Tables and synopsis lines | Which flags are public |
| Conventional commits | projection | Past-tense Changed bullets | Breaking versus non-breaking label |
| Test names only | projection | A list of observed behaviors | Whether a test is a supported contract |
| None | promise | Nothing | Migration, support, security, pricing |
If a page has no row that fits, it is not ready for generation and should stay out of the model queue. Mixing rows inside one file recreates the original review bottleneck that the graph was meant to remove. When a tutorial must show a request, link to the compiled reference and keep the tutorial itself in promise until the example is a tested fixture.
Limitations
The scanner is a heuristic, not a proof of normative absence across the entire documentation set. A promise can be smuggled as "callers typically retry three times" without RFC 2119 words, and this job will not catch that sentence. The compiler trusts OpenAPI summaries, so a wrong spec produces a wrong projection with high confidence and a clean scanner. Conventional-commit summaries also lie when authors write feat: on a breaking rename, which is why the breaking label remains a human field.
The graph does not replace legal review for privacy statements, and it does not measure readability or translation quality. Teams that publish a single narrative book instead of a reference tree will find the file-level role flag too coarse for that shape. In that case, split the book into a compiled appendix and a human-owned preface before applying any of the rewrite rules above.
Who should not use this approach
Do not adopt this workflow if the product has no machine-readable schema and no command that can dump a public surface. Do not adopt it if release managers cannot name an owner for docs/migration.md who can delay a tag. Do not send customer-specific runbooks through a shared model queue, because those runbooks are promises and often contain tenant topology.
Skip the model rewrite entirely when descriptions in the spec are already the published voice of the product. In that case the compiler alone is the documentation system, and an extra language-model hop only adds drift between the spec and the site. Skip the free-server rewrite as well when the documentation corpus is private legal text that should not leave the existing review path.
Closing
Projection pages should be treated as build output, regenerated on every spec change, and scanned for legislative language before merge. Promise pages should stay out of the model queue, even when a free server makes generation feel inexpensive compared with a writer round. If a docs CI job already compiles the site, that same job can compile the graph, reject normative leaks, and leave migration text for the humans who will be paged when the promise is wrong.
If that split already matches an existing docs pipeline, MonkeyCode's free server option is one place to run the compiler and the description-only rewrite without expanding the model's brief.
Top comments (0)