Generated API documentation fails most often when a model writes a requirement that no specification line can support. The control is simple: every MUST, SHOULD, SLA figure, and retry interval must cite a source or be stripped. Models may restate field names, types, and enumerated values that already exist in OpenAPI or JSON Schema. Humans retain ownership of guarantees, support timelines, and any verb that creates an operational commitment for callers.
This article describes a two-pass documentation pipeline that treats requirement language as a merge hazard rather than a style issue. The first pass restates only JSON pointers listed in a draft-surface file extracted from the spec. The second pass is a deterministic scanner that deletes unowned RFC 2119 verbs and bare quantitative claims. Nothing below assumes production metrics; the programs are labeled proposals you can run against a local OpenAPI file.
Split restatement from commitment
API reference text mixes two kinds of sentences that look similar in a rendered page. Restatements name a path, a method, a parameter, or an enum member that already exists in the contract. Commitments tell a caller what the service will do under load, during incidents, or after a deprecation starts. A fluent model will emit the second kind unless the pipeline removes those tokens before review.
| Token class | Example fragment | Model may draft? | Human must own? |
|---|---|---|---|
| JSON pointer facts |
GET /orders has query status of type string
|
Yes, if the pointer is on the draft surface | No, unless the wording adds policy |
| Enum members |
status accepts open, paid, void
|
Yes, copied from schema.enum
|
No |
| RFC 2119 verbs | Clients MUST retry on 503 |
No, unless a src pointer already sits on that line |
Yes |
| Quantities with units |
p99 under 200ms, 99.95% monthly
|
No | Yes |
| Calendar promises | supported through 2027-03-01 |
No | Yes |
| Recovery advice | use exponential backoff and page on-call |
No | Yes |
The table is the editorial contract, not a model prompt trick. If a line cannot point at /paths, /components/schemas, or a human-owned file, it does not survive the stripper. Reviewers then spend time on commitments instead of hunting invented SLAs inside otherwise accurate field lists.
Step 1. Project a draft surface from OpenAPI
The draft surface is a YAML allowlist of JSON pointers the restatement pass is allowed to mention. Building it from the spec keeps the model from inventing sibling endpoints that happen to sound plausible. The script below is a proposal: it walks a 3.x OpenAPI document and emits pointers for paths, methods, parameters, and schema enums only.
# propose: project_draft_surface.py
from __future__ import annotations
import json
import sys
from pathlib import Path
import yaml
ALLOWED_METHODS = {"get", "post", "put", "patch", "delete", "head", "options"}
def load_spec(path: Path) -> dict:
text = path.read_text(encoding="utf-8")
if path.suffix in {".yaml", ".yml"}:
return yaml.safe_load(text)
return json.loads(text)
def project(spec: dict) -> dict:
pointers: list[dict] = []
paths = spec.get("paths") or {}
for path_key, path_item in paths.items():
escaped = path_key.replace("~", "~0").replace("/", "~1")
for method, op in (path_item or {}).items():
if method not in ALLOWED_METHODS or not isinstance(op, dict):
continue
base = f"/paths/{escaped}/{method}"
pointers.append({"pointer": base, "allow": ["operationId", "tags"]})
for index, param in enumerate(op.get("parameters") or []):
if not isinstance(param, dict):
continue
pointers.append({
"pointer": f"{base}/parameters/{index}",
"allow": ["name", "in", "required", "schema.type", "schema.enum"],
})
return {
"source": str(spec.get("info", {}).get("title", "unknown")),
"pointers": pointers,
}
def main() -> None:
spec_path = Path(sys.argv[1])
out_path = Path(sys.argv[2])
surface = project(load_spec(spec_path))
out_path.write_text(yaml.safe_dump(surface, sort_keys=False), encoding="utf-8")
print(f"wrote {len(surface['pointers'])} pointers to {out_path}")
if __name__ == "__main__":
main()
Run it against a checked-in contract so the allowlist is reviewable in the same pull request as the spec change. A docs change that names a pointer absent from this file is either a spec gap or an invented route.
python project_draft_surface.py openapi.yaml .docs/draft_surface.yaml
Step 2. Constrain the restatement pass to that allowlist
The generation prompt is not free-form documentation. It receives the draft-surface YAML plus the raw spec fragments those pointers resolve to, and it is told to emit Markdown that only restates allow fields. Label the next block as an unexecuted prompt template, not as a measured evaluation.
# proposal: restatement prompt (not executed here)
You receive JSON Pointers and the spec fragments they resolve to.
Write one Markdown section per pointer.
Use only keys listed in `allow` for that pointer.
Do not use MUST, SHALL, SHOULD, REQUIRED, GUARANTEE, SLA, or percentages.
Do not invent paths, status codes, timeouts, or calendar dates.
If a field is absent from the fragment, omit it rather than guessing.
The restatement pass is a batch rewrite over JSON pointers, so it does not need a dedicated editorial workstation. Disclosure: This article was prepared as part of MonkeyCode's product outreach. MonkeyCode's free model access and free server option can host that rewrite worker when the team does not want to attach a paid runner. The stripper and the draft-surface file stay in ordinary CI, which keeps ownership rules independent of whichever model produced the draft.
Human-owned files live beside the generated tree, not inside it. A practical layout keeps generated restatements in docs/generated/ and commitments in docs/owned/, with the owned tree referenced by explicit includes. Callers still read one site, but merge rules differ by directory.
<!-- docs/owned/availability.md is human-authored -->
## Availability commitments
<!-- src: docs/owned/availability.md -->
The service MUST publish a 30-day notice before removing `GET /orders`.
Step 3. Strip requirement verbs and bare quantities that lack a pointer
Pass two is deterministic on purpose. Regular expressions are crude, but they are stable across model versions and do not require another inference call. The scanner keeps a line only when a nearby HTML comment names a source file, a JSON pointer, or an owned document. Otherwise it drops the sentence that contains the token and records the deletion.
# propose: strip_unowned.py
from __future__ import annotations
import re
import sys
from pathlib import Path
VERB_RE = re.compile(
r"\b(MUST|SHALL|SHOULD|REQUIRED|GUARANTEE|GUARANTEED|SLA)\b",
re.IGNORECASE,
)
QUANTITY_RE = re.compile(
r"\b\d+(?:\.\d+)?\s*(?:ms|s|%|percent|nines)\b|\b99\.\d+%",
re.IGNORECASE,
)
SRC_RE = re.compile(r"<!--\s*src:\s*([^>]+)-->")
SENTENCE_RE = re.compile(r"[^.\n]+\.?\n?")
def has_src(window: str) -> bool:
return SRC_RE.search(window) is not None
def strip_text(markdown: str) -> tuple[str, list[str]]:
kept: list[str] = []
removed: list[str] = []
lines = markdown.splitlines(keepends=True)
for index, line in enumerate(lines):
window = "".join(lines[max(0, index - 1) : index + 2])
if not VERB_RE.search(line) and not QUANTITY_RE.search(line):
kept.append(line)
continue
if has_src(window):
kept.append(line)
continue
for sentence in SENTENCE_RE.findall(line):
if VERB_RE.search(sentence) or QUANTITY_RE.search(sentence):
removed.append(sentence.strip())
else:
kept.append(sentence)
return "".join(kept), removed
def main() -> None:
path = Path(sys.argv[1])
original = path.read_text(encoding="utf-8")
cleaned, removed = strip_text(original)
path.write_text(cleaned, encoding="utf-8")
log = path.with_suffix(path.suffix + ".stripped.log")
log.write_text("\n".join(removed) + ("\n" if removed else ""), encoding="utf-8")
print(f"stripped {len(removed)} fragments from {path}")
if __name__ == "__main__":
main()
The log file is the review artifact. A non-empty log does not mean the author failed; it means the model attempted a commitment and the pipeline refused to publish it. Editors move surviving commitments into docs/owned/ with an explicit src comment, or they delete the claim.
Step 4. Fail CI only after strip, and only on leftover tokens
Failing on the raw model output creates noisy pull requests and trains authors to weaken the scanner. Fail after the stripper, on any remaining unowned verb or quantity inside docs/generated/. Owned files are exempt because their src comments point at themselves by policy.
# proposal: ci fragment
set -euo pipefail
python project_draft_surface.py openapi.yaml .docs/draft_surface.yaml
# restatement worker writes docs/generated/*.md using draft_surface.yaml
find docs/generated -name '*.md' -print0 | while IFS= read -r -d '' f; do
python strip_unowned.py "$f"
done
python - <<'PY'
from pathlib import Path
import re, sys
verb = re.compile(r"\b(MUST|SHALL|SHOULD|REQUIRED|GUARANTEE|SLA)\b", re.I)
qty = re.compile(r"\b\d+(?:\.\d+)?\s*(?:ms|s|%|percent)\b", re.I)
src = re.compile(r"<!--\s*src:")
failed = 0
for path in Path("docs/generated").rglob("*.md"):
lines = path.read_text(encoding="utf-8").splitlines()
for i, line in enumerate(lines):
window = "\n".join(lines[max(0, i-1): i+2])
if (verb.search(line) or qty.search(line)) and not src.search(window):
print(f"{path}:{i+1}: unowned commitment token")
failed += 1
sys.exit(1 if failed else 0)
PY
The order matters. Projection first, restatement second, strip third, token check last. Reversing strip and check will fail every draft that contains a stray SHOULD, including drafts the stripper would have cleaned in the next line.
What this does not prove
The scanner does not understand English scope, negation, or tables that split a number across cells. It will delete a harmless SHOULD inside a quoted error string if that string lacks a src comment on an adjacent line. It will also miss a commitment written as callers are expected to retry, because that phrasing avoids the verb list on purpose.
JSON pointers do not encode runtime defaults that exist only in server code. If a default timeout lives in an environment variable and never appears in OpenAPI, the restatement pass will omit it, which is correct for this workflow. Documenting that timeout remains a human-owned paragraph with a pointer into the configuration repository, not a model-authored SLA.
RFC 2119 remains a language convention, not a test harness. Binding MUST to a spec pointer does not verify that production traffic honors the sentence. Pair this pipeline with contract tests if the claim is behavioral; the stripper only prevents unowned wording from reaching the published tree.
Who should not use this approach
Do not adopt cite-or-strip as the only control when the artifact is a legal terms page, a status-page incident promise, or a paid SLA exhibit. Those documents need counsel and an explicit signer, not a regex. Skip the workflow if the team has no machine-readable spec and wants narrative onboarding guides; the draft surface would be empty and the model would have nothing legitimate to restate.
Tutorial prose that teaches an operator how to think, rather than how a path is shaped, also fits poorly. The allowlist has no pointer for judgment, metaphors, or warnings about business process. Keep those in docs/owned/ from the first draft and do not route them through the restatement worker.
Teams that already merge generated reference pages without a spec SHA in front matter will fight the stripper on every change. Introduce the draft-surface file first, then enable deletion of unowned verbs, then fail CI. Turning all three on in one pull request hides whether failures come from missing pointers, aggressive regexes, or genuine invented guarantees.
Top comments (0)