Generated API docs fail most often at the heading level, not at the token level. Models fill every empty section because prompts treat an outline as a request to complete. Human promises then hide inside overviews, authentication notes, and example narratives that no schema can support. The practical fix is to assign draft rights per heading before any model run starts.
Why heading rights beat after-the-fact filters
Post-generation linters still allow the model to invent a support policy, then ask a reviewer to delete it. That pattern wastes review time and trains teams to skim generated prose instead of checking evidence. A heading that the model should never write should never appear in the prompt, the context pack, or the output schema. Reviewers then inspect only tables and restatements that a schema, fixture, or OpenAPI path can support.
Section ownership also keeps tutorial pages and reference pages from collapsing into one generated blob. Reference tables can be compiled from machine-readable sources with little narrative risk. Tutorial headings almost always encode product intent, audience assumptions, and operational promises that remain human work. Mixing those classes in a single completion is the usual source of silent policy drift.
Ownership classes you can encode
Treat every heading as one of four classes before a draft job is scheduled. Record the class in version control beside the doc tree, not inside a chat transcript. The classes below are a proposal for public HTTP APIs; other products will need extra rows.
| Class | Model may draft | Human must own | Allowed sources |
|---|---|---|---|
schema_table |
yes | no | OpenAPI, JSON Schema, protobuf |
recorded_example |
yes | no | checked-in fixtures, reviewed HAR |
identifier_index |
yes | no | frozen path and field lists |
promise_prose |
no | yes | none; leave a stub |
promise_prose covers purpose statements, authentication policy, rate limits, SLAs, deprecation calendars, support contacts, and any "you will typically" sentence. recorded_example covers request and response bodies only when a named fixture file is cited on the same heading. If a heading has no source column, classify it as promise_prose by default, and refuse to treat the gap as draftable work.
Workflow
Follow these numbered steps on a branch that already has an OpenAPI file and a docs outline. Label the commands as a proposed local workflow; they are not reported production metrics. Keep the manifest in the same pull request as heading edits so classification cannot drift from the rendered tree.
1. Inventory headings from the outline
Export the heading tree before any model sees the repository. A small script is enough when the outline already lives in Markdown, and the output should be discarded after the YAML manifest exists.
# proposal: extract ATX headings from a stub outline
python3 - <<'PY'
from pathlib import Path
import re
text = Path("docs/api/outline.md").read_text(encoding="utf-8")
for i, line in enumerate(text.splitlines(), 1):
m = re.match(r"^(#{2,4})\s+(.*)$", line)
if m:
print(f"{len(m.group(1))}|{m.group(2).strip()}|{i}")
PY
Keep the document title under human control, and inventory H2 through H4 as the units you will grant or deny. Store the printed rows only until ownership.yaml exists, then delete the ad-hoc list so the YAML file remains the only authority. Outline drift after that point should fail closed in continuous integration rather than being repaired by another model pass.
2. Write a section ownership manifest
Create docs/api/ownership.yaml with stable heading identifiers, not generated prose. Identifiers should match the outline after a light slug rule so CI can join rows to rendered headings without fuzzy matching.
# proposal: docs/api/ownership.yaml
version: 1
slug_rule: "lower-hyphen"
sections:
- id: overview
heading: "Overview"
class: promise_prose
- id: authentication
heading: "Authentication"
class: promise_prose
- id: endpoints
heading: "Endpoints"
class: schema_table
sources: ["openapi/openapi.yaml"]
- id: error-envelope
heading: "Error envelope"
class: schema_table
sources: ["openapi/components/schemas/Error.yaml"]
- id: create-order-example
heading: "Create order example"
class: recorded_example
sources: ["fixtures/http/create_order_201.json"]
- id: rate-limits
heading: "Rate limits"
class: promise_prose
- id: deprecation
heading: "Deprecation"
class: promise_prose
- id: support
heading: "Support"
class: promise_prose
Refuse to start a draft job when any heading in the outline lacks a row, because missing classification is not an invitation to generate. Add a row, or delete the heading from the public outline, before the next step runs. A heading without sources is still not draftable; it is an incomplete human section that must stay a stub.
3. Carve the model prompt down to draftable sections
Build the prompt from the manifest rather than from the full Markdown outline. Include only schema_table, recorded_example, and identifier_index rows, plus the files listed in sources. Omit promise_prose headings entirely so the model cannot complete them, even with hedging language that looks review-friendly.
# proposal: tools/carve_doc_prompt.py
from pathlib import Path
import yaml
manifest = yaml.safe_load(Path("docs/api/ownership.yaml").read_text())
DRAFTABLE = {"schema_table", "recorded_example", "identifier_index"}
parts = ["Restate only the listed sources. Do not add headings.", ""]
for section in manifest["sections"]:
if section["class"] not in DRAFTABLE:
continue
parts.append(f"## {section['heading']}")
parts.append(f"class: {section['class']}")
for src in section.get("sources", []):
body = Path(src).read_text(encoding="utf-8")
parts.append(f"SOURCE {src}")
parts.append(body)
parts.append("")
Path("tmp/draftable-prompt.md").write_text("\n".join(parts), encoding="utf-8")
The carved file is the only model input for the draft job. Do not paste README marketing, issue comments, or prior chat answers into that file, because those texts are promise-shaped even when they look like documentation. Truncated schemas are also unsafe inputs; pass complete source files for every draftable heading.
A later step in this workflow can send only draftable headings to a free model endpoint. Disclosure: This article was prepared as part of MonkeyCode's product outreach. MonkeyCode's free model access and free server option can host that narrow draft job and the ownership check without granting extra section rights.
4. Reassemble stubs so human headings stay empty
After the model returns Markdown, join it with the original outline by heading identifier rather than by paragraph similarity. Copy model output only under draftable headings, and write a fixed stub under every promise_prose heading so reviewers see the gap instead of fluent filler. Similarity matching is a merge bug here, because a cautious quota paragraph can still look like a neighboring field table.
<!-- HUMAN_OWNED:rate-limits -->
_This section is empty on purpose. A human must write rate-limit numbers,
burst behavior, and enforcement language after an operations review._
Never let the assembler copy a model paragraph into a stub, even when the paragraph looks cautious, because cautious language about quotas is still a promise. The stub comment is the merge contract: generated files may replace schema_table bodies and must preserve HUMAN_OWNED markers byte-for-byte. Regeneration jobs should overwrite only draftable bodies and must leave stub files untouched.
5. Fail CI when human-owned headings gain prose
The checker below is a proposed gate, not a measured production suite. It flags three failures: unknown headings, draftable headings without a source citation, and human-owned headings whose body lost the marker. Run it beside your OpenAPI validator so a docs change cannot merge with only a green HTML build.
# proposal: tools/check_doc_ownership.py
import re, sys, yaml
from pathlib import Path
ROOT = Path("docs/api")
manifest = yaml.safe_load((ROOT / "ownership.yaml").read_text())
text = (ROOT / "reference.md").read_text(encoding="utf-8")
parts = re.split(r"\n(?=## )", text)
bodies = {}
for part in parts:
first, _, rest = part.partition("\n")
title = first.replace("## ", "", 1).strip()
bodies[title] = rest
errors = []
known = {s["heading"] for s in manifest["sections"]}
for heading in bodies:
if heading and heading not in known:
errors.append(f"unknown heading: {heading}")
DRAFTABLE = {"schema_table", "recorded_example", "identifier_index"}
for section in manifest["sections"]:
body = bodies.get(section["heading"], "")
if section["class"] == "promise_prose":
marker = f"HUMAN_OWNED:{section['id']}"
if marker not in body:
errors.append(f"missing stub marker: {section['id']}")
prose = body.replace(marker, "")
if re.search(r"\b(will|must|typically|guarantee|SLA)\b", prose, re.I):
errors.append(f"promise language under {section['id']}")
elif section["class"] in DRAFTABLE:
for src in section.get("sources", []):
if src not in body:
errors.append(f"missing source citation {src} in {section['id']}")
if errors:
print("\n".join(errors))
sys.exit(1)
print("ownership check passed")
python3 tools/check_doc_ownership.py
A pretty site that invented a rate limit is still a broken contract. Treat checker failures as merge blockers equal to a broken schema, not as documentation nits for a later cleanup ticket.
What the model may draft, restated
Under this workflow the model may restate field tables, path lists, and fixture-backed examples. It may rename columns only when the manifest says the source already contains those names. It may not write overviews, security rationale, migration dates, or client SDK tutorials that imply an official library. It also may not invent status codes, default values, or enum members that are absent from the cited source file.
Humans own every sentence that would still be true if the OpenAPI file were deleted. That test is coarse and useful during review, because it separates restatement from promise without a semantics model. If the paragraph survives deletion of machine sources, it is a promise, and the heading should have been promise_prose before the draft job ran.
Limitations
The checker does not understand meaning; it understands markers, citations, and a small English word list. A model can still smuggle a promise into a schema_table description if the OpenAPI file already contains that promise. Garbage sources still produce garbage tables, so source review remains a human gate that this workflow does not replace.
Slug mismatches will fail closed, which is correct and annoying for writers who retitle sections late. Heading edits require a manifest edit in the same pull request, and multilingual sites must not auto-translate promise_prose stubs. Free model drafts will still hallucinate enum values when the source file is truncated, so the carve script must pass complete schemas rather than summaries.
The free server option is only useful here as a place to run carve, complete, and check as one job. It does not replace the ownership file, and it does not make a heading draftable. Teams that skip the manifest and only host the model are not following this workflow.
Who should not use this approach
Do not use heading rights if your public docs are primarily narrative essays with no schema. The manifest would mark almost every section human-owned, and the model would have nothing honest to draft. Do not use it to launder marketing pages through a generator so they look like reference material, because the output would still be promise_prose with extra steps.
Skip the workflow when a regulator requires all-human authorship with no model in the pipeline at all. The right move then is to keep models off the docs branch, not to classify headings and hope a stub survives review. Also skip it for internal wikis where incomplete stubs would confuse readers more than a slightly wrong paragraph would, because empty sections are a user-facing cost.
Review order that keeps classes honest
Reviewers should read ownership.yaml before reference.md, and they should stop when a heading class looks wrong. Class errors are cheaper to fix than mixed prose that already sounds like the rest of the site. After classes look right, review source citations, then read only the draftable bodies, leaving stubs for a later human commit.
Human stubs should be filled in a separate commit from generated tables. That split keeps blame useful and prevents a regenerate step from clobbering a signed rate-limit paragraph. When a regenerate is needed, rerun carve and check, then leave promise_prose files untouched so signed sentences cannot be rewritten by a restater.
If you already draft schema tables from a free model endpoint, keep the ownership check on the same worker that writes reference.md. The check is the product of this workflow; the model is only a restater of files you already trust.
Top comments (0)