API documentation goes stale in two different ways, and treating them as one problem produces unreviewable pages. Derived material such as example payloads and status tables can be compiled from files the repository already contains. Commitment language such as retry policy and compatibility windows cannot be recovered from those files, so a human must own it. The working rule is simple: models may write under derived/, and generators must refuse to touch owned/.
Mixed files hide the judgment line
A single api.md file usually interleaves parameter tables with sentences that promise behavior under failure. Reviewers then cannot tell which paragraphs a fixture can prove and which paragraphs need a product owner. Drafting models amplify that mix because they complete both kinds of sentence in the same confident register. Teams that already generate reference docs still leak guarantees into the generated block unless layout forbids it.
Silence is the failure mode that looks like productivity, because a drafting pass still emits a complete page. A model that cannot find a deprecation date will still write a plausible sunset sentence for the reader. The same completion habit shows up in agent workflows that invent missing IAM details instead of stopping. Documentation pipelines therefore need a stop condition: no write permission on files that encode commitments.
What belongs in each lane
The derived lane restates extractable facts: paths, required fields, example bodies, and documented status codes. Those facts live in OpenAPI documents, recorded HTTP fixtures, and exception-to-code maps checked into git. The owned lane states decisions: when to call an endpoint, how long old clients remain compatible, and what retries mean. If a sentence would still be true after deleting the spec file, it does not belong in derived/.
Keep the two lanes in separate directories so git history, CODEOWNERS, and CI can treat them differently. Generated Markdown should be boring restatement with citations back to facts.json for every table row. Owned Markdown should be short, dated, and attributed to a role rather than to a drafting model. Mixing the two inside one section recreates the original review problem, only this time with nicer headings.
Proposed repository layout
This layout is a proposal for a documentation package, not a report of a production deployment. Replace path names if your publishing tool requires a different collection structure; the invariant is the write boundary, not the labels. Review tools should enforce that boundary even when the site generator later concatenates both folders into one sidebar.
docs/
derived/
cookbook.md # generated, citations required
status-table.md
error-catalog.md
owned/
when-to-use.md # human, CODEOWNERS required
compatibility.md
retry-and-idempotency.md
facts.json # compiled extract, not prose
tools/
extract_facts.py
render_cookbook.py
lint_derived.py
Decision table: draft versus sign
Use the table before any prompt is written, because the prompt cannot repair a missing source. Review-only still means a human glances at the compiled table after generation, and it does not authorize invented rows. Empty sources must produce empty tables, not helpful guesses that keep the cookbook looking complete.
| Doc unit | Recoverable source | Model may draft | Human must sign |
|---|---|---|---|
| Path and method list | OpenAPI paths
|
Yes | Review only |
| Required field table | OpenAPI required / JSON Schema |
Yes | Review only |
| Example request bodies | OpenAPI examples or tests/fixtures/*.json
|
Yes | Review only |
| Status code catalog | OpenAPI responses
|
Yes | Review only |
| Error code list | Exception map or application/problem+json schema |
Yes | Review only |
| When callers should use the endpoint | Product intent, support policy | No | Yes |
| Compatibility window for old clients | Release calendar | No | Yes |
| Retry, timeout, and idempotency promises | SRE or API governance policy | No | Yes |
| Statements of the form "we never store X" | Security or legal review | No | Yes |
| Deprecation dates and migration deadlines | Versioning decision | No | Yes |
Workflow
1. Freeze extractable facts into one JSON file
Compile a fact file from OpenAPI and fixtures so later drafting cannot browse the rest of the repository. The extract is the only input the renderer and the model are allowed to see during cookbook generation. Anything not present in that JSON file must appear as an explicit gap, never as a completed paragraph.
# tools/extract_facts.py — proposed helper, unexecuted example
from __future__ import annotations
import json
import sys
from pathlib import Path
try:
import yaml
except ImportError as exc:
raise SystemExit("install pyyaml before running this extract helper") from exc
def load_openapi(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 example_from_operation(op: dict) -> dict | None:
body = (op.get("requestBody") or {}).get("content") or {}
app_json = body.get("application/json") or {}
examples = app_json.get("examples") or {}
if examples:
first = next(iter(examples.values()))
return first.get("value")
if "example" in app_json:
return app_json["example"]
schema = app_json.get("schema") or {}
return schema.get("example")
def extract(openapi: dict) -> dict:
facts = {"paths": []}
for path, item in (openapi.get("paths") or {}).items():
for method, op in item.items():
if method.startswith("x-") or not isinstance(op, dict):
continue
responses = sorted((op.get("responses") or {}).keys())
facts["paths"].append(
{
"path": path,
"method": method.upper(),
"operationId": op.get("operationId"),
"required": ((op.get("requestBody") or {})
.get("content", {})
.get("application/json", {})
.get("schema", {})
.get("required") or []),
"example": example_from_operation(op),
"status_codes": responses,
"source": f"openapi.yaml#paths.{path}.{method}",
}
)
return facts
def main(argv: list[str]) -> int:
spec = Path(argv[1] if len(argv) > 1 else "openapi.yaml")
out = Path(argv[2] if len(argv) > 2 else "docs/facts.json")
facts = extract(load_openapi(spec))
out.parent.mkdir(parents=True, exist_ok=True)
out.write_text(json.dumps(facts, indent=2), encoding="utf-8")
print(f"wrote {len(facts['paths'])} path facts to {out}")
return 0
if __name__ == "__main__":
raise SystemExit(main(sys.argv))
Run the extract in CI so docs/facts.json cannot drift from the spec that merged in the same pull request. A failing diff is cheaper than a later support ticket about an example that never existed. Keep the fact file committed so reviewers can inspect the extract without rerunning local tooling.
python tools/extract_facts.py openapi.yaml docs/facts.json
git diff --exit-code -- docs/facts.json
2. Render cookbooks only under derived/
Deterministic rendering should be the default, because tables do not need a model if the fact file is complete. Use a model only to turn cited examples into a readable cookbook paragraph that still ends with a source path. Disclosure: This article was prepared as part of MonkeyCode's product outreach. When a team wants a hosted drafting pass, MonkeyCode's free model access and free server option can rewrite facts.json into docs/derived/. Feed the model the fact file and a template; do not attach owned/ files or ask for guarantees.
# tools/render_cookbook.py — proposed helper, unexecuted example
from __future__ import annotations
import json
from pathlib import Path
TEMPLATE = """# Derived cookbook
<!-- generated from docs/facts.json; do not edit -->
{sections}
"""
SECTION = """## `{method} {path}`
- Source: `{source}`
- Required JSON fields: {required}
- Documented status codes: {codes}
Example body (copied from the spec or fixture, not invented):
json
{example}
"""
def render(facts: dict) -> str:
parts = []
for row in facts["paths"]:
example = row.get("example") or {"_note": "no example in source"}
parts.append(
SECTION.format(
method=row["method"],
path=row["path"],
source=row["source"],
required=", ".join(row["required"]) or "(none listed)",
codes=", ".join(row["status_codes"]) or "(none listed)",
example=json.dumps(example, indent=2),
)
)
return TEMPLATE.format(sections="\n".join(parts))
if __name__ == "__main__":
facts = json.loads(Path("docs/facts.json").read_text(encoding="utf-8"))
out = Path("docs/derived/cookbook.md")
out.parent.mkdir(parents=True, exist_ok=True)
out.write_text(render(facts), encoding="utf-8")
codeowners
If you still use a model for prose around the table, constrain the prompt to verbs that restate: lists, copies, and cites. Ban commitment verbs such as guarantee, always, never, and forever, because those claims are not in the fact file. The renderer above already shows the safer path: copy the JSON and cite the source path beside it.
3. Block the generator from writing owned/
CODEOWNERS and a CI diff are cheaper than prompt instructions, and they still work when someone pastes output by hand. Wire the script into the generate job after render, not into the human documentation pull request. Humans must be able to edit owned/ without the generator fighting them on every documentation change. The generator must not edit owned/ even when a prompt claims the model is being careful about policy.
# .github/CODEOWNERS
/docs/owned/ @api-governance
# tools/check_owned_untouched.sh
set -euo pipefail
if git diff --name-only -- docs/owned | grep -q .; then
echo "owned docs changed in a generation job; aborting" >&2
exit 1
fi
4. Lint derived/ for promise language
A word list is not a semantic proof, but it catches the usual leakage from a drafting model. Fail the build when derived files contain commitment verbs that the fact file cannot support. Run the lint on the generated tree only, so owned policy pages can still use the word compatible when a human means it.
# tools/lint_derived.py — proposed helper, unexecuted example
from __future__ import annotations
import re
import sys
from pathlib import Path
PROMISE = re.compile(
r"\b(guarantee|guaranteed|always|never|sla|compatible|backward[- ]compatible|"
r"we will|must remain|forever|99\.\d+|uptime)\b",
re.I,
)
def main(root: Path) -> int:
failed = 0
for path in sorted(root.rglob("*.md")):
for i, line in enumerate(path.read_text(encoding="utf-8").splitlines(), 1):
match = PROMISE.search(line)
if match:
print(f"{path}:{i}: promise verb {match.group()!r} in derived lane")
failed += 1
return 1 if failed else 0
if __name__ == "__main__":
raise SystemExit(main(Path(sys.argv[1] if len(sys.argv) > 1 else "docs/derived")))
Pair the lint with a short allowlist only when a derived page quotes an error string containing a banned word. Quoting an upstream error is restatement; promising the same words as platform policy is not. Keep that exception in the lint configuration rather than in a free-form apology from the model.
5. Sign owned pages with a role and a date
Owned files should name a decision, a date, and a role that can be asked follow-up questions. A model cannot be that role, because it cannot accept operational consequences when the decision is wrong. The signature belongs in the file header so review tools can grep for unsigned policy pages.
# Compatibility window
- Owner role: API governance
- Last signed: 2026-09-07
- Decision: clients on the v1 JSON envelope remain readable through 2026-12-31.
- Non-goals: this page does not restate request examples; see `docs/derived/cookbook.md`.
The date in that template is a placeholder for whatever day a human actually signs the decision. Do not let a generator fill the signature fields on an owned page during a cookbook refresh. CI can require Owner role and Last signed headers without understanding the policy text itself.
Test plan for the pipeline
Treat the documentation generator as a compiler with golden files, not as a chat transcript. The checks below are a proposed acceptance list for the helpers in this article. They do not report measurements from a production docs site.
- Given an OpenAPI file with one path and an example,
extract_facts.pywrites exactly onepathsrow. - Given a missing example, the cookbook contains the
_noteobject rather than a plausible payload. - Given a derived file that includes the word
guarantee,lint_derived.pyexits nonzero. - Given a generate job,
git diff --name-only -- docs/ownedis empty. - Given a human edit to
docs/owned/compatibility.md, CODEOWNERS requests the governance reviewer.
Those five checks are enough to stop the common failure where a helpful paragraph appears in the generated tree. Add fixture coverage when you introduce a new recoverable source, including problem+json schemas and recorded HAR files. If a new source cannot be extracted into facts.json, it is not a derived-lane input yet.
Limitations
Verb linting will both over-flag and under-flag, because English commitments are not a regular language. OpenAPI examples are often stale or handwritten, so compiling them does not make those examples true. The layout does not prevent a human from pasting a promise into derived/ and merging without reading lint. It also does not replace legal review for privacy statements, even when those statements live under owned/.
This workflow assumes you already have a machine-readable spec or a set of recorded fixtures. Without those sources there is no derived lane, and a model would be drafting from chat memory. That missing-source case is the situation this pipeline is designed to refuse rather than to complete.
Who should not use this approach
Skip the two-directory split if the document is a one-page README with no API surface. Skip it for design RFCs that are entirely judgment and contain no extractable tables or status maps. Skip it if your publishing tool cannot mount two folders as one navigation tree without rewriting paths. Skip it when the examples in the spec are themselves marketing fiction, because compiling fiction only freezes it.
What this changes in review
Reviewers can read derived/ as they read compiled code: look for extract bugs, not for product intent. They can read owned/ as they read a policy change: look for dates, owners, and expensive sentences. The drafting model stays useful inside the first lane because restating a fact file is a bounded task. It stays out of the second lane because silence there is safer than a fluent assumption.
Top comments (0)