Library documentation fails when generated prose and human policy share one file without an ownership gate. Models can draft parameter tables from exports, but they cannot sign deprecation windows, legal notices, or operational limits. This article proposes a two-lane workflow: compile reference stubs from source, then require a human signature file before publish. The artifact is a small extractor, a section ledger, and a checker that refuses unsigned claims.
Why mixed ownership breaks reviews
Reviewers treat a README as a single document even when half of it was drafted from a model session. Parameter tables that match today's exports remain cheap to regenerate after a function rename in the tree. Sentences about uptime, export control, or support through a calendar date are commitments rather than compile output. Mixing those lanes inside one blob hides which paragraphs must be re-signed after each release.
A second failure mode is silent drift between exported symbols and the published prose in README files. Exports change, the generated stub goes stale, and the nearby policy paragraph still looks freshly written to reviewers. The opposite case also happens when a model rewrites a legal paragraph because adjacent reference text changed. Both cases are documentation defects that automated checks can catch once ownership is explicit in the tree.
A complete parameter table is not the same artifact as a signed support statement. Treating them as one Markdown file makes review theater look like completion. The rest of this workflow keeps compile output and commitments in different paths, then assembles them only after a lane check passes.
Decision table: draft versus own
Use the table as a contract for README and adjacent docs pages. Anything in the human column is invalid inside generated Markdown.
| Section | Model may draft | Human must own | Evidence the checker accepts |
|---|---|---|---|
| Public function names and signatures | Yes, from AST or inspect
|
Only naming disputes | Extractor JSON matching HEAD |
| Parameter and return type tables | Yes, from types or docstrings | Types when the language is untyped | Extractor JSON plus type stub |
| Minimal usage snippet shape | Yes, as unlabeled pseudocode | Runtime, credentials, side effects |
status: proposal on the stub |
| When not to use this API | Outline bullets only | Final wording |
signed_by on the section |
| Deprecation and removal dates | Never | Always | ISO date in the signature file |
| Support window and SLA language | Never | Always | Signature file, not model output |
| Secrets, tokens, environment setup | Variable names only | Values, rotation, production rules | Human file; CI greps for secrets |
| Legal, license, export, privacy | Never | Always | Signature file |
| Performance or quota claims | Never | Always, with a cited method | Signature file plus method note |
The table is intentionally boring. Boring ownership rules survive release week better than a single prompt that claims to write the whole README.
1. Extract a facts file from source, not from chat
Run a deterministic extractor against the library you are documenting. The output is JSON the drafting model may read, not prose the model may invent. If a symbol is missing from this file, it does not belong in generated reference Markdown.
# extract_api_facts.py — labeled example, not a measured production service
from __future__ import annotations
import ast
import json
import sys
from pathlib import Path
def public_functions(path: Path) -> list[dict]:
tree = ast.parse(path.read_text(encoding="utf-8"))
rows = []
for node in tree.body:
if isinstance(node, ast.FunctionDef) and not node.name.startswith("_"):
args = [a.arg for a in node.args.args if a.arg != "self"]
ret = ast.unparse(node.returns) if node.returns else None
rows.append(
{
"name": node.name,
"args": args,
"returns": ret,
"lineno": node.lineno,
"source": str(path),
}
)
return rows
def main() -> None:
root = Path(sys.argv[1])
facts = []
for py in sorted(root.rglob("*.py")):
if "test" in py.parts or py.name.startswith("test_"):
continue
facts.extend(public_functions(py))
json.dump({"functions": facts, "lane": "compile"}, sys.stdout, indent=2)
sys.stdout.write("\n")
if __name__ == "__main__":
main()
python extract_api_facts.py ./src > .docs/facts.generated.json
Commit the extractor and the facts schema. Do not commit a chat transcript as if it were the facts file. Regeneration must be a command, not a remembered conversation.
2. Keep a human signature file beside the generated stub
Store policy in a file the extractor never writes. Reviewers sign sections, not the entire README blob. Dates and names below are format examples; replace them with values a named reviewer actually accepted.
# .docs/policy.signed.yaml — humans edit this file only
schema: docs-section-ledger/v1
signed_by: "docs-owner@example.com"
signed_at: "2026-09-22"
sections:
- id: deprecation
heading: "Deprecation and removal"
status: owned
text: |
Endpoint fetch_widget remains callable through 2026-12-31.
Removal is scheduled for 2027-03-01 in the 3.x line.
- id: support_window
heading: "Support window"
status: owned
text: |
Security fixes land on the latest minor only.
No SLA is offered for the community distribution.
- id: when_not
heading: "When not to use this library"
status: owned
text: |
Do not call this client from a request thread that cannot retry.
Do not store API tokens in repository files or generated stubs.
- id: legal
heading: "License and privacy"
status: owned
text: |
Distributed under Apache-2.0 as stated in LICENSE.
This library does not define a data-retention policy for your logs.
The signature file is the only place calendar promises are allowed to live. If a date is not in this file, the assembler must not print it.
3. Let a model draft only the compile lane
Disclosure: This article was prepared as part of MonkeyCode's product outreach. Free model access and a free server option can turn facts.generated.json into a reference stub, provided the prompt forbids policy headings. That draft still does not replace policy.signed.yaml, and it must not invent symbols the extractor omitted.
Proposed prompt, labeled as a proposal rather than a production policy:
Read .docs/facts.generated.json.
Write Markdown only for heading "Generated function reference".
For each function, emit a table of name, args, and returns.
Mark every usage snippet as proposal, not as a tested tutorial.
Do not invent functions.
Do not write headings named Deprecation, Support, SLA, License, Privacy, or Performance.
Do not state quotas, uptime, or retention.
Write the stub to .docs/reference.generated.md. Do not let the drafting session rewrite the signature file. If the stub disagrees with the facts JSON, discard the stub and keep the facts.
4. Assemble README from both lanes in CI
Keep a template that only contains tokens, not mixed prose. Generated files remain generated. Signed files remain signed. The published README is an assembly output, which means reviewers can diff each lane on its own.
<!-- README.template.md is assembled, not hand-mingled -->
# widgetclient
{{POLICY:when_not}}
## Generated function reference
{{GENERATED_REFERENCE}}
{{POLICY:deprecation}}
{{POLICY:support_window}}
{{POLICY:legal}}
# assemble_readme.py — labeled example
from pathlib import Path
import re
import yaml
root = Path(".docs")
template = Path("README.template.md").read_text(encoding="utf-8")
generated = (root / "reference.generated.md").read_text(encoding="utf-8")
policy = yaml.safe_load((root / "policy.signed.yaml").read_text(encoding="utf-8"))
by_id = {row["id"]: row["text"].rstrip() for row in policy["sections"]}
out = template.replace("{{GENERATED_REFERENCE}}", generated.rstrip())
out = re.sub(
r"\{\{POLICY:([a-z_]+)\}\}",
lambda m: by_id[m.group(1)],
out,
)
Path("README.md").write_text(out + "\n", encoding="utf-8")
5. Fail the build on lane violations
The checker is a mechanical ally for reviewers. It does not prove that policy text is true. It only proves the compile lane stayed in its lane and that a signature header exists.
# check_docs_lanes.py — labeled example
from __future__ import annotations
import re
import sys
from pathlib import Path
POLICY_HEADINGS = (
r"^#+ (Deprecation|Support window|SLA|License|Privacy|Performance|Retention)\b"
)
UNSIGNED_CLAIMS = (
r"\b(SLA|uptime|99\.\d+%|retain(?:s|ed|ing)? for|supported until|we guarantee)\b"
)
def main() -> int:
generated = Path(".docs/reference.generated.md").read_text(encoding="utf-8")
errors = []
for i, line in enumerate(generated.splitlines(), 1):
if re.search(POLICY_HEADINGS, line, re.I):
errors.append(f"generated:{i}: policy heading in compile lane: {line}")
if re.search(UNSIGNED_CLAIMS, line, re.I):
errors.append(f"generated:{i}: unsigned claim in compile lane: {line}")
policy = Path(".docs/policy.signed.yaml").read_text(encoding="utf-8")
if "signed_by:" not in policy or "signed_at:" not in policy:
errors.append("policy: missing signed_by or signed_at")
for err in errors:
print(err, file=sys.stderr)
return 1 if errors else 0
if __name__ == "__main__":
raise SystemExit(main())
python extract_api_facts.py ./src > .docs/facts.generated.json
# draft .docs/reference.generated.md from facts only, then:
python check_docs_lanes.py && python assemble_readme.py
Wire those three commands after unit tests and before the publish job. A red lane check is cheaper than a support email that quotes a generated uptime sentence nobody signed.
Limitations
The extractor understands a subset of Python ast function definitions at module scope. It does not parse C extensions, generated protobuf stubs, or plugins registered only at runtime. Teams that document those surfaces still need a compiled facts source; they should not move those names into chat-authored prose.
The unsigned-claim regex is a tripwire, not counsel and not a linguist. Clever phrasing can evade it, which is why humans still read policy.signed.yaml before merge. A green job means the lanes did not leak. It does not mean the support window is accurate.
Free-model drafts can omit a function the extractor listed or misrender a type. The merge rule stays simple: facts JSON wins over stub Markdown every time. The assembler must not repair that conflict by inventing a third version of the name.
Who should not use this approach
Skip the split if the document is a single narrative essay with no public API surface. The compile lane would be empty, and the ledger would add process without reducing review risk. Skip it if the plan is to launder legal copy through a model and then paste the result into the signature file. Named reviewers belong on that file, not a drafting session.
Regulated products that forbid generated text on any customer-facing page should keep the published README entirely in the signed lane. The extractor can still produce an internal facts file for reviewers. It should not emit customer Markdown in that environment.
Close the loop in git, not in chat history
Generated reference text is a build artifact. Policy text is a reviewed commitment. Keep them in different files, assemble them in CI, and refuse merges that let a model own dates, support language, or legal sentences. If API facts already land in continuous integration, add the lane check beside that job and keep signatures in git rather than in a disposable draft.
Top comments (0)