Published documentation should be compiled from a reviewed facts file, not pasted from a model transcript into git. Freehand generation mixes extractable facts with unowned advice, so reviewers cannot tell which sentences survive the next commit. This article describes a two-stage compiler: extract typed facts, then render Markdown only from those facts plus a human advise file. The model may draft the facts file; a human must own every advisory sentence that reaches readers.
Mixed claim classes are the actual defect
Most generated READMEs fail for a structural reason rather than a problem of tone or fluency. A single paragraph will list a real flag from argparse, then promise a stability window that no test encodes. Later diffs change the flag default, while the promise remains in prose because no build step owned it. Reviewers then argue about tone instead of checking whether the sentence is still an extractable fact.
The useful documentation split is not a debate about artificial voice, house style, or author identity. FACT records can be checked against a schema, a flag table, an enum, or an OpenAPI document. ADVISE records tell a reader what to do, what is supported, or what remains true next quarter. Models are acceptable drafters for FACT records, while humans remain the only owners of ADVISE records.
Keep two inputs and one renderer
Treat docs/facts.json as an intermediate representation, similar to an object file produced by a C compiler. Treat docs/advise.yaml as a separately reviewed source that the renderer may quote but never invent. Treat docs/generated/*.md as build output that CI must regenerate and that nobody commits from a chat window. If a sentence cannot be produced by the renderer from those two inputs, it does not ship.
The following schema is a proposal for a small HTTP API and is not an executed extract from a live service. Required keys force every facts file to name an api_id, a source commit, and a list of operations. Operations must carry method, path, status codes, and field types copied from the machine-readable contract. Descriptions, overviews, and compatibility sentences have no field in this schema, and that omission is intentional.
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"title": "DocsFacts",
"type": "object",
"required": ["api_id", "source_commit", "operations"],
"properties": {
"api_id": { "type": "string" },
"source_commit": { "type": "string", "pattern": "^[0-9a-f]{7,40}$" },
"operations": {
"type": "array",
"items": {
"type": "object",
"required": ["operation_id", "method", "path", "status_codes", "fields"],
"properties": {
"operation_id": { "type": "string" },
"method": { "enum": ["GET", "POST", "PUT", "PATCH", "DELETE"] },
"path": { "type": "string" },
"status_codes": {
"type": "array",
"items": { "type": "integer" }
},
"fields": {
"type": "array",
"items": {
"type": "object",
"required": ["name", "required", "type"],
"properties": {
"name": { "type": "string" },
"required": { "type": "boolean" },
"type": { "type": "string" }
}
}
}
}
}
}
}
}
An advise file holds only human-owned records that the renderer may quote as blockquotes. Each record names an operation_id, a human owner, a review date, and an audience. Models may not add keys to this file, even when the facts extract discovers a new operation. A new operation without advise still renders, but it renders as tables only, without guidance.
# docs/advise.yaml — human-owned; models may not add keys
version: 1
rules:
- operation_id: listWidgets
owner: docs-oncall
reviewed_on: "2026-09-10"
audience: public
text: "Use listWidgets for catalog reads. Create flows stay on POST /widgets."
- operation_id: listWidgets
owner: docs-oncall
reviewed_on: "2026-09-10"
audience: public
text: "Pagination defaults are not a compatibility promise; pin page size in clients."
Numbered compile workflow
Follow this compile sequence on every contract change, including OpenAPI edits and enum additions. The sequence is ordered so mixed claim classes fail before any Markdown file exists. Skipping any step in this sequence usually reintroduces chat transcripts into the published tree. Keep the commands in Makefile targets so humans do not reconstruct them from memory.
- Point the extractor at OpenAPI, JSON Schema, CLI parsers, or error enums, never at existing Markdown.
- Emit
facts.jsonwith asource_commitpin so CI can fail when HEAD and facts diverge. - Reject FACT records that contain advisory language by scanning values for modal and support tokens.
- Load
advise.yamlas a second input and fail on missing owner, review date, or unknownoperation_idvalues. - Render Markdown in CI only, because the renderer must not accept a free-text prompt.
- Diff the generated tree against git and send fixes to facts, advise, or the renderer.
# proposal — pin these targets in CI rather than reconstructing them in chat
.PHONY: docs
docs:
python extract_facts.py openapi.json
python check_facts_language.py docs/facts.json
python render_docs.py docs/facts.json docs/advise.yaml docs/generated
Extractor sketch
The extractor below is unexecuted example code that reads a simplified OpenAPI document and writes facts.json. It copies methods, paths, status codes, and field types, and it refuses to invent descriptions. Missing operationId values fail the job, because generated headings need a stable key across commits. Advisory tokens inside identifiers also fail the job, because mixed claim classes start at extract time.
# extract_facts.py — proposal, not a measured benchmark
from __future__ import annotations
import json
import subprocess
import sys
from pathlib import Path
ADVISORY_TOKENS = (
"must", "should", "always", "never", "guarantee",
"supported", "stable", "will not", "recommend",
)
def git_head() -> str:
return subprocess.check_output(
["git", "rev-parse", "HEAD"], text=True
).strip()
def has_advisory_language(value: str) -> bool:
lowered = value.lower()
return any(token in lowered for token in ADVISORY_TOKENS)
def extract_operations(spec: dict) -> list[dict]:
operations = []
for path, methods in spec.get("paths", {}).items():
for method, body in methods.items():
if method.upper() not in {"GET", "POST", "PUT", "PATCH", "DELETE"}:
continue
op_id = body.get("operationId")
if not op_id:
raise SystemExit(f"missing operationId for {method.upper()} {path}")
if has_advisory_language(op_id):
raise SystemExit(f"advisory token in operationId: {op_id}")
statuses = sorted(int(code) for code in body.get("responses", {}))
fields = []
schema = (
body.get("requestBody", {})
.get("content", {})
.get("application/json", {})
.get("schema", {})
)
required = set(schema.get("required", []))
for name, prop in schema.get("properties", {}).items():
fields.append(
{
"name": name,
"required": name in required,
"type": prop.get("type", "unknown"),
}
)
operations.append(
{
"operation_id": op_id,
"method": method.upper(),
"path": path,
"status_codes": statuses,
"fields": fields,
}
)
return operations
def main() -> None:
spec_path = Path(sys.argv[1])
spec = json.loads(spec_path.read_text())
facts = {
"api_id": spec.get("info", {}).get("title", "unnamed"),
"source_commit": git_head(),
"operations": extract_operations(spec),
}
Path("docs/facts.json").write_text(json.dumps(facts, indent=2) + "\n")
if __name__ == "__main__":
main()
Run the extractor as a documented command in CI, not as a side effect of a chat session. The three commands below extract facts, render Markdown, and fail if generated files were edited by hand. Developers should treat a diff in docs/generated as a compiler output change, similar to a change in dist/. Commit the generated tree only when the pipeline produced it from reviewed facts and advise inputs.
python extract_facts.py openapi.json
python check_facts_language.py docs/facts.json
python render_docs.py docs/facts.json docs/advise.yaml docs/generated
git diff --exit-code -- docs/generated
Renderer rules
The renderer is deliberately boring, because boring output is easier to review than fluent model prose. Each operation becomes one heading, one field table, one status list, and zero or more advise blockquotes. No introductory essay is emitted, and no overview paragraph is emitted by the template. If writers need an overview, they add it as an ADVISE record with a named owner.
# render_docs.py — proposal
from __future__ import annotations
import json
import sys
from pathlib import Path
import yaml
def render(facts: dict, advise: dict) -> str:
lines = [
f"# {facts['api_id']} reference",
"",
f"Source commit: `{facts['source_commit']}`.",
"",
"This page is generated. Edit `docs/facts.json` or `docs/advise.yaml`.",
"",
]
advise_by_op: dict[str, list[str]] = {}
for rule in advise.get("rules", []):
advise_by_op.setdefault(rule["operation_id"], []).append(rule["text"])
for op in facts["operations"]:
lines.append(f"## `{op['method']} {op['path']}`")
lines.append("")
lines.append(f"operation_id: `{op['operation_id']}`")
lines.append("")
lines.append("Status codes: " + ", ".join(str(c) for c in op["status_codes"]))
lines.append("")
lines.append("| Field | Required | Type |")
lines.append("| --- | --- | --- |")
for field in op["fields"]:
lines.append(
f"| `{field['name']}` | {field['required']} | `{field['type']}` |"
)
lines.append("")
for text in advise_by_op.get(op["operation_id"], []):
lines.append(f"> {text}")
lines.append("")
return "\n".join(lines)
def main() -> None:
facts = json.loads(Path(sys.argv[1]).read_text())
advise = yaml.safe_load(Path(sys.argv[2]).read_text())
out_dir = Path(sys.argv[3])
out_dir.mkdir(parents=True, exist_ok=True)
(out_dir / "reference.md").write_text(render(facts, advise))
if __name__ == "__main__":
main()
CI gate that blocks mixed classes
A third check scans advise text for missing owners and scans facts strings for advisory tokens. Keep this check in the same job that renders, so a green pipeline means the published tree is reproducible. check_facts_language.py should walk every string in facts.json and fail on the same ADVISORY_TOKENS tuple. Do not special-case field names like must_understand without an allowlist, because silent exceptions recreate mixed classes.
# check_facts_language.py — proposal
from __future__ import annotations
import json
import sys
from pathlib import Path
from typing import Any
ADVISORY_TOKENS = (
"must", "should", "always", "never", "guarantee",
"supported", "stable", "will not", "recommend",
)
def walk(node: Any, path: str) -> None:
if isinstance(node, dict):
for key, value in node.items():
walk(value, f"{path}.{key}")
return
if isinstance(node, list):
for index, value in enumerate(node):
walk(value, f"{path}[{index}]")
return
if isinstance(node, str):
lowered = node.lower()
for token in ADVISORY_TOKENS:
if token in lowered:
raise SystemExit(f"advisory token {token!r} at {path}: {node}")
def main() -> None:
payload = json.loads(Path(sys.argv[1]).read_text())
walk(payload, "$")
if __name__ == "__main__":
main()
# .github/workflows/docs-compile.yml — proposal
name: docs-compile
on: [push, pull_request]
jobs:
compile:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: "3.12"
- run: pip install pyyaml
- run: python extract_facts.py openapi.json
- run: python check_facts_language.py docs/facts.json
- run: python render_docs.py docs/facts.json docs/advise.yaml docs/generated
- run: git diff --exit-code -- docs/generated
Decision table: model draft versus human ownership
Use the following decision table before adding any prompt that writes documentation into the repository. The table is the policy, and the compiler is that policy made mechanical for CI. If a requested sentence has no row, add a row before you add a prompt. Do not hide new claim classes inside examples, captions, callouts, or alternative text on diagrams.
| Unit | Model may draft | Human must own | Compiler action |
|---|---|---|---|
| HTTP method and path | Yes, from OpenAPI | Schema accuracy | Extract into facts |
| Status code list | Yes, from responses map | Completeness versus tests | Extract into facts |
| Field name and type | Yes, from JSON Schema | Naming collisions | Extract into facts |
| Default values | Yes, if present in schema | "Default will not change" | Fact only; promise is advise |
| Example payloads | Yes, if bound to schema | "Copy this in production" | Fact fixture; advise for use |
| When to call the endpoint | No | Yes |
advise.yaml only |
| Compatibility window | No | Yes |
advise.yaml only |
| Support and SLA language | No | Yes |
advise.yaml only |
| Deprecation calendar | No | Yes |
advise.yaml only |
| Overview essays | No | Yes, or omit | Renderer emits none |
Read the table as a permission list rather than as writing advice for tone. A model may fill any cell marked Yes, but only into facts.json and only from the cited artifact. A human must fill every No cell, and the renderer must refuse to invent those sentences.
Where a hosted extract job fits
A local extractor is enough when one repository owns one OpenAPI file and one facts schema. A shared runner becomes useful when several packages must emit the same facts shape for a multi-service reference site. Disclosure: This article was prepared as part of MonkeyCode's product outreach. MonkeyCode's free model access and free server option can run the facts extraction pass without turning published Markdown into a chat transcript. The model still must not write docs/generated files or append keys to advise.yaml under any runner.
Limitations
This compiler does not replace technical writing for conceptual guides, architecture decision records, or incident reports. Those documents are almost entirely ADVISE, so a facts file adds little structure and much process. The extractor shown here only understands a narrow OpenAPI slice and will miss callbacks, webhooks, and non-JSON content types. Extend the extractor only when a new surface can be pinned to a file and a commit.
Token matching on must and should is a blunt instrument for detecting mixed claim classes. Domain fields named must_retry need an allowlist, and that allowlist is itself a human-owned policy. The renderer also cannot prove that advise.yaml is true; it can only prove that advice is attributed, dated, and attached to a known operation. Truth of an advise record remains a review problem, not a problem the parser can close.
Commit pinning fails open if the extract job is skipped on branches that only touch documentation. Require the job on every change to openapi.json, the docs tree, and the compiler scripts. Do not treat a skipped docs job as a green build, because stale facts.json will still render cleanly. Branch protection should list this workflow as required before merge to the default branch.
Who should not use this approach
Do not adopt this compiler if your documentation is primarily narrative onboarding, because almost every sentence would land in advise.yaml. In that case the facts file stays empty and the extra CI job becomes ceremony. Do not adopt it if no owner can be named for public promises, because CI will fail on missing owner fields. Teams that then skip the job are worse off than teams that never added it.
Do not adopt it for legal disclaimers or security commitments that require counsel review outside this pipeline. Skip it when the API has no machine-readable contract, because invented facts recreate the original defect with extra files. Start from OpenAPI, JSON Schema, protobuf, or a CLI parser that already exists in the repository. If those artifacts do not exist, write them before generating reference documentation from a model.
If you compile a small public API this way, compare docs/generated/reference.md with the current README and count how many modal verbs had no owner. Each unowned modal is a sentence that should have lived in advise.yaml or should not have shipped. The compiler does not make documentation friendlier on its own; it makes ownership visible before merge. That visibility is the entire return on the extra files and the extra CI job.
Top comments (0)