Generated documentation usually fails at warranty custody rather than at missing section headings or empty parameter tables. A model can draft structure from public symbols, but it cannot honestly own runtime behavior, compatibility, or support. Treat every sentence that a user might quote as a promise as human-owned, and keep the model inside a draftable lane. The rest of this article specifies a custody ledger, a scanner, and a five-step generation workflow.
Generated prose hides two claim classes
When a generator fills a README from signatures, it often invents outcomes that never appear in tests. Users later copy those generated sentences into support issues and treat them as binding product contracts. The documentation set then contains two kinds of text that look identical inside ordinary Markdown files. One kind only restates exported names, parameter types, and file paths that the current repository already proves.
The other kind asserts behavior under failure, upgrade, concurrency, elapsed time, or mixed version deployments. Maintainers need a mechanical split rather than a style guide that reviewers forget under deadline pressure. Draftable text may be regenerated on every tag because it is a projection of public symbols. Human-owned text must survive regeneration and must fail CI when it appears without a named owner.
The scanner below encodes that split as a custody ledger plus a set of forbidden unowned patterns. Nothing in this workflow measures writing quality, tone, or marketing clarity for an external landing page. It only asks whether a claim is a projection of the tree or a warranty that still needs an owner. If the claim is a warranty, the model must not be the last writer on that sentence.
Custody table: what a model may draft
Use the table as a review contract rather than as a prompt appendix that nobody opens. Rows marked draftable can be rebuilt from the repository without any human affidavit on the branch. Rows marked owned must keep a named owner, a change date, and a pointer to evidence. Cross-links sit in the middle because path existence is checkable while usefulness still needs a person.
| Claim class | Model may draft | Human must own | Evidence |
|---|---|---|---|
| Heading tree from public modules | Yes | No | Module walk |
| Parameter names and annotated types | Yes | No | Signatures |
| Identifier glossary | Yes | No | Symbol index |
| Cross-links between doc pages | Yes | Review | Path existence |
| Defaults copied from AST literals | Yes, with citation | Confirm | AST defaults |
| Copy-paste commands that imply a run | No | Yes | Owned run stamp |
| Version compatibility and upgrade paths | No | Yes | Release policy |
| Failure, retry, and timeout behavior | No | Yes | Tests |
| Security, privacy, and threat statements | No | Yes | Security review |
| Latency, complexity, and throughput | No | Yes | Measured notes |
| Stability labels such as production-ready | No | Yes | Release train |
| Support windows and deprecation dates | No | Yes | Versioning policy |
The table is intentionally conservative about examples, because fenced code reads like a demonstration that somebody executed. A model can propose a command shape from a CLI parser, but it cannot attest that the command printed that output today. Compatibility language is also owned because it binds future releases, not the current abstract syntax tree. Security sentences are owned because they are legal and operational statements rather than identifier restatements.
Artifact: a custody ledger the scanner can read
Keep the human custody ledger in docs/custody.yaml so a regeneration pass cannot silently rewrite owners. The file lists draftable surfaces, owned claim patterns, and the people who may sign affidavits. Patterns are lowercase tokens that usually turn a description into a warranty when they appear in user-facing Markdown. Owners are stable identifiers, not display names, so git blame and CODEOWNERS can stay aligned.
version: 1
draftable_surfaces:
- headings
- signature_tables
- identifier_glossary
- intra_doc_links
owned_claim_patterns:
- always
- never
- guaranteed
- compatible
- supports
- secure
- thread-safe
- zero-downtime
- backwards-compatible
- production-ready
- o(1)
- latency
- sla
owners:
- id: release
files: ["docs/support.md", "CHANGELOG.md"]
- id: runtime
files: ["docs/failures.md"]
- id: examples
files: ["docs/getting-started.md"]
Place owned claims in Markdown using an HTML comment that the renderer ignores and the scanner parses. The comment records owner, claim class, and a short evidence pointer such as a test path. Unsigned prose that still contains an owned pattern is a gate failure, even when the surrounding paragraph looks polished. Example fences need a run stamp immediately above the fence, or the scanner treats the block as an implied demonstration.
<!-- custody:owned owner=runtime claim=failure evidence=tests/test_retry.py -->
On retryable transport errors the client waits two seconds, then attempts one retry.
<!-- custody:run owner=examples evidence=tests/test_cli_help.py -->
bash
python -m inventory.cli symbols --format json
python
That HTML comment format is the regeneration boundary between draftable files and owned files. Draftable files may be overwritten by a generator after each public API change lands. Owned comments and the sentences they cover must not be overwritten unless the named owner edits them in the same change. If a generator cannot preserve those comments, it must write to a *.draft.md sibling and leave the owned file untouched.
Scanner: fail CI on unowned warranties
The scanner is a small Python program that maintainers can run locally and inside CI. It loads the ledger, walks Markdown under docs/, and reports unowned warranties with file and line numbers. It also flags fenced blocks that lack a preceding run stamp, because those blocks impersonate executed examples. The script below is a worked example, not a published benchmark and not a claim about any hosted model.
#!/usr/bin/env python3
"""Fail when user-facing docs contain unowned warranty language."""
from __future__ import annotations
import pathlib
import re
import sys
import yaml
OWNED_RE = re.compile(
r"<!--\s*custody:owned\s+owner=(?P<owner>\S+)\s+"
r"claim=(?P<claim>\S+)\s+evidence=(?P<evidence>\S+)\s*-->"
)
RUN_RE = re.compile(
r"<!--\s*custody:run\s+owner=(?P<owner>\S+)\s+evidence=(?P<evidence>\S+)\s*-->"
)
FENCE_RE = re.compile(r"^```
")
TOKEN_RE = re.compile(r"[A-Za-z0-9+()/.^-]+")
def load_ledger(path: pathlib.Path) -> dict:
data = yaml.safe_load(path.read_text(encoding="utf-8"))
if not data or "owned_claim_patterns" not in data:
raise SystemExit(f"invalid custody ledger: {path}")
patterns = [p.lower() for p in data["owned_claim_patterns"]]
owners = {row["id"] for row in data.get("owners", [])}
return {"patterns": patterns, "owners": owners}
def iter_markdown(root: pathlib.Path) -> list[pathlib.Path]:
return sorted(p for p in root.rglob("*.md") if p.is_file())
def scan_file(path: pathlib.Path, ledger: dict) -> list[str]:
lines = path.read_text(encoding="utf-8").splitlines()
errors: list[str] = []
active_owned: set[str] = set()
pending_run = False
for idx, raw in enumerate(lines, start=1):
owned = OWNED_RE.search(raw)
if owned:
if owned.group("owner") not in ledger["owners"]:
errors.append(f"{path}:{idx}: unknown owner {owned.group('owner')}")
active_owned.add(owned.group("claim").lower())
pending_run = False
continue
run = RUN_RE.search(raw)
if run:
if run.group("owner") not in ledger["owners"]:
errors.append(f"{path}:{idx}: unknown run owner {run.group('owner')}")
pending_run = True
continue
if FENCE_RE.match(raw):
if raw.strip() != "
```" and not pending_run:
errors.append(
f"{path}:{idx}: example fence without custody:run stamp"
)
pending_run = False
continue
if raw.startswith("#") or raw.startswith("|") or raw.startswith("<!--"):
continue
tokens = {tok.lower() for tok in TOKEN_RE.findall(raw)}
hits = [p for p in ledger["patterns"] if p in tokens or p in raw.lower()]
unsigned = [h for h in hits if h not in active_owned]
if unsigned:
errors.append(
f"{path}:{idx}: unowned claims {unsigned}: {raw.strip()[:80]}"
)
active_owned.clear()
return errors
def main() -> int:
root = pathlib.Path("docs")
ledger = load_ledger(pathlib.Path("docs/custody.yaml"))
failures: list[str] = []
for path in iter_markdown(root):
failures.extend(scan_file(path, ledger))
if failures:
sys.stderr.write("\n".join(failures) + "\n")
return 1
sys.stdout.write(
f"custody ok: {len(list(iter_markdown(root)))} markdown files\n"
)
return 0
if __name__ == "__main__":
raise SystemExit(main())
The ownership window is one following Markdown line after an owned comment, which keeps affidavits local and reviewable. Example fences reset the pending run flag so a stamp cannot cover a later block on the same page. Heading lines and table rows are skipped because they are draftable surfaces in the ledger. Maintainers should still read tables, because a table cell can hide a compatibility promise; the scanner is a net, not a substitute for review.
Five-step generation workflow
1. Inventory public symbols from the tree
Walk only exported modules, CLI entry points, and checked-in schema files rather than chat history. Record each symbol with its module path, signature, and default literals when those defaults exist in source. Write the inventory to docs/_inventory.json so later steps do not re-parse the tree with a different policy. The symbol inventory is draftable output, and it should be regenerated whenever public symbols change.
python -m inventory.cli symbols --src src --out docs/_inventory.json
git diff --check docs/_inventory.json
That command is a shape, not a signed run, until a maintainer adds a custody run stamp in the getting-started page. The inventory must omit private helpers, because private names invite documentation of unstable call paths. If the language has an explicit export list, trust that list over recursive directory walking alone. Reject files that the published package does not ship, including tests and local helper scripts.
2. Emit a draftable skeleton into sibling files
Generate headings, signature tables, and a glossary into docs draft siblings rather than into the owned pages. Constrain the generator to copy identifiers from _inventory.json and to leave behavior sentences as empty placeholders. Empty placeholders are safer than fluent guesses, because fluent guesses look finished during hurried review. Diff the draft against the previous draft so reviewers see structural drift instead of rewritten warranties.
python tools/emit_doc_skeleton.py \
--inventory docs/_inventory.json \
--out-dir docs/drafts \
--surfaces headings,signature_tables,identifier_glossary
The drafting pass only needs a place to run a constrained prompt against the symbol inventory and emit Markdown skeletons.
Disclosure: This article was prepared as part of MonkeyCode's product outreach.
MonkeyCode's free model access and free server option can host that isolated drafting step without extra hardware. Do not send owned affidavits or unpublished security notes to any drafting host, including a free remote shell. Keep the owned ledger and the scanner on the repository's normal CI, because those files are the contract, not the model output.
3. Promote structure, then hand-write owned claims
Copy stable headings and signature tables from the draft sibling into the owned Markdown file only. Do not copy narrative paragraphs, sample outputs, or any sentence that contains an owned pattern. Write failure behavior, support windows, and compatibility notes under custody comments with explicit evidence pointers. If evidence is missing, delete the sentence instead of asking the generator to complete the claim.
## retry()
| Argument | Type | Default |
| --- | --- | --- |
| attempts | int | 2 |
<!-- custody:owned owner=runtime claim=failure evidence=tests/test_retry.py -->
On retryable transport errors the client waits two seconds, then attempts one retry.
Promotion is a three-way merge among the previous owned file, the new draft, and the tests that evidence citations name. If a test path in an evidence field does not exist, the change is incomplete even when the scanner has not yet run. Owners listed in docs/custody.yaml should match CODEOWNERS so review routing and affidavit identity stay the same. Refuse to promote a draft that rewrites an owned paragraph without a matching test-suite change.
4. Stamp examples only after a local run
Run each getting-started command in a clean environment and paste the real output under a run stamp. If the output depends on wall-clock time or generated identifiers, replace those spans with placeholders and say so in the owned sentence. Never let the generator invent exit codes, because exit codes become support contracts the moment they appear. Delete examples that cannot be reproduced from the published package on a clean machine.
python -m inventory.cli symbols --format json > /tmp/symbols.json
python tools/stamp_example.py \
--cmd "python -m inventory.cli symbols --format json" \
--out docs/getting-started.md
The stamping helper should store the command, working directory, and package version beside the fence. That metadata is owned evidence, not decorative front matter for a static documentation site. If two examples share a prefix, stamp them separately so a later edit cannot reuse a stale run. Broken examples are documentation bugs, and they should fail the same CI job as unowned warranty tokens.
5. Gate the branch with the custody scanner
Run the scanner after promotion and after example stamping, not before, or it will flag unfinished drafts. Wire it as a required check so generated prose cannot land with always, secure, or production-ready left unsigned. Keep the job cheap: it reads Markdown and a YAML ledger, and it should finish in seconds. Fail closed when the ledger is missing, because a missing policy is not an empty policy.
# .github/workflows/doc-custody.yml
# Adapt action versions to current forge docs for your runners.
name: doc-custody
on: [push, pull_request]
jobs:
scan:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: "3.12"
- run: pip install pyyaml pytest
- run: python tools/scan_doc_custody.py
- run: PYTHONPATH=tools pytest tests/test_scan_doc_custody.py
The workflow file is a template maintainers must adapt to their forge, runner labels, and Python version. Pinning ubuntu-latest without reading the forge changelog is a separate operations risk this article does not claim to solve. The important property is that custody failure blocks merge the same way a unit-test failure blocks merge. Documentation generation that cannot pass this gate is still a draft, regardless of how complete it reads.
Tests for the scanner itself
A custody gate that nobody tests will rot into a linter people skip. Keep a tiny fixture tree under tests/fixtures/docs with one clean page and one dirty page. The clean fixture page has one owned failure claim and one stamped command example. The dirty fixture page uses backwards-compatible in unsigned prose and an unstamped code fence.
from pathlib import Path
from scan_doc_custody import load_ledger, scan_file
def test_clean_page_has_no_errors(tmp_path: Path) -> None:
docs = tmp_path / "docs"
docs.mkdir()
(docs / "custody.yaml").write_text(
"owned_claim_patterns: [always]\nowners: [{id: runtime, files: []}]\n",
encoding="utf-8",
)
page = docs / "ok.md"
page.write_text(
"<!-- custody:owned owner=runtime claim=always evidence=tests/t.py -->\n"
"The mapper always emits UTF-8.\n",
encoding="utf-8",
)
ledger = load_ledger(docs / "custody.yaml")
assert scan_file(page, ledger) == []
def test_unsigned_warranty_is_reported(tmp_path: Path) -> None:
docs = tmp_path / "docs"
docs.mkdir()
(docs / "custody.yaml").write_text(
"owned_claim_patterns: [secure]\nowners: [{id: runtime, files: []}]\n",
encoding="utf-8",
)
page = docs / "bad.md"
page.write_text("The proxy is secure by default.\n", encoding="utf-8")
ledger = load_ledger(docs / "custody.yaml")
errors = scan_file(page, ledger)
assert errors and "unowned claims" in errors[0]
Those tests lock the intended split: draftable restatement stays silent, and unsigned warranties stay noisy. They do not prove that a generator will obey the sibling-file rule, which remains a process control. If a team later adds HTML docs or generated sites, they must extend the walker rather than assuming Markdown is the only surface. The custody idea is independent of the file format; the scanner in this article is not.
Limitations and who should skip this
This workflow is a documentation control plane, not a writing assistant and not a substitute for product legal review. Teams without tests cannot fill evidence pointers, and empty evidence fields make the affidavits decorative. Single-file scripts with no public API surface will spend more time maintaining the ledger than they will save. Security advisories, license grants, and privacy statements need specialist owners sitting outside this scanner.
The pattern list will both under-match and over-match, because English warranties are not a closed token set. supports in "the parser supports UTF-8" may be an owned compatibility claim or a restatement of a codec name. Human review still decides promotion, and the scanner only catches the obvious unsigned tokens after promotion. Do not treat a green custody job as proof that the docs are true, only that owned language was claimed by a named person.
Generated headings can still be wrong when the inventory policy includes private modules or omits shipped entry points. Wrong structure is cheaper to fix than wrong warranties, which is why this article puts the model on the structure side. If a maintainer cannot name an owner for a sentence, the sentence does not belong in the published set. Delete it, move it to an internal design note, or wait for a test that can serve as evidence.
What this changes in review
Reviewers can stop arguing about whether generated prose sounds confident enough for a 1.0 README. They can ask a narrower question: which sentences are projections of public symbols, and which sentences are warranties. Warranties need owners, dates of a kind encoded in git, and evidence paths that exist on the branch. Everything else can be regenerated without extra ceremony when the public API surface moves.
The custody ledger also makes multi-maintainer documentation less tribal across rotating reviewers on a library. A new contributor can add a signature table without inheriting responsibility for retry timing they have never run. The runtime owner remains responsible for failure prose, even when the generator refreshed the heading tree in the same pull request. That separation of draftable structure and owned behavior is the entire method of this workflow.
Top comments (0)