If a model drafts part of your documentation, the cheapest safe rule is short: generated text is never edited in place, and human text is never generated. Enforce that split with two directories and one build step, and most documentation rot stops before it starts. Model output becomes a cache you can discard and rebuild, while human judgment lives in files the generator is forbidden to touch.
The core conclusion first: the expensive failure in model-drafted docs is not bad prose, it is mixed authorship inside one paragraph. Once a reviewer fixes a generated sentence in place, nobody can tell which lines are derived from source and which lines are hand-written promises. A build that merges two separately owned layers keeps that question answerable forever.
Why in-place editing is the expensive choice
Suppose a reference page has forty blocks, and a reviewer hand-fixes six generated paragraphs during release week. On the next regeneration you face a binary choice, and both branches lose. Overwrite the file and the six corrections vanish without a diff anyone reads; skip regeneration and the page slowly diverges from the schema it claims to describe.
The same trap appears at smaller scale in every code review. A generated endpoint table is corrected from memory, the OpenAPI file is corrected separately, and the two corrections disagree by the next sprint. Splitting ownership removes the ambiguity: derived blocks are rebuilt from source, owned blocks are edited by a named person, and the build fails when the boundary is crossed.
What the model may draft, and what a human must own
Use this table as a starting classification, then adjust it to your own risk tolerance. The deciding question is not length or difficulty; it is whether the sentence can be mechanically checked against a file in the repository.
| Content | Owner | Reason |
|---|---|---|
| Endpoint parameter tables from an OpenAPI file | model | Every row is verifiable against the schema |
CLI flag reference from --help output | model | Regenerable, and drift shows up as a diff |
| Changelog summaries from merged commit subjects | model, then reviewed | Grouping is mechanical, emphasis is not |
| Migration order and rollback steps | human | Ordering constraints are operational promises |
| Deprecation policy, SLA, security warnings | human | These are commitments, not descriptions |
| "Why we chose X over Y" rationale | human | The reasoning is not present in any source file |
| License and attribution text | human, pinned | Must never be paraphrased by a model |
Two rules make the table enforceable rather than aspirational. First, a model block must declare its source files, and the build re-hashes those files to detect staleness. Second, a human block must have a named overlay path, and the generator script refuses to write there at all.
Step 1 — Split the page tree into three layers
Keep inputs, generated output, and owned prose in separate directories so that git status tells you which layer changed. The layout below assumes a Python toolchain and a POSIX shell, and it works with any static site generator that reads Markdown.
docs/manifest.json # ownership and page assembly, the only source of truth prompts/endpoint.md # generator instruction per block type src/, openapi/ # model inputs, ordinary source files gen/api/auth-token.md # model output, never edited by hand own/security/rotation.md # human-owned prose, never touched by the generator build/api/auth.md # assembled output, regenerated, git-ignored or committed tools/gen_block.py tools/build_docs.py tools/check_docs.py The gen/ and own/ split is the whole idea, and everything else exists to protect it. Generated files carry a machine header, owned files carry no header, and neither layer ever imports text from the other by hand.
Step 2 — Write the manifest that names an owner per block
Page assembly order comes from manifest order, which keeps the diff of a reordering readable. Every model block lists its sources; every human block lists its overlay file.
{ "blocks": { "api/auth-token": { "owner": "model", "page": "api/auth.md", "sources": ["openapi/auth.yaml", "src/auth/token.py"], "generator": "prompts/endpoint.md" }, "security/token-rotation": { "owner": "human", "page": "security/auth.md", "overlay": "own/security/token-rotation.md" }, "release/upgrade-notes": { "owner": "human", "page": "release/upgrade.md", "overlay": "own/release/upgrade-notes.md" } } } Step 3 — Stamp provenance at generation time
The model cannot compute hashes, so a wrapper script computes them after the model returns and writes the header itself. Two hashes matter: sources_hash detects stale input, and body_sha detects a hand edit inside gen/.
#!/usr/bin/env python3 """Generate one doc block, then stamp provenance so later edits are detectable.""" import argparse, hashlib, json, pathlib, sys ROOT = pathlib.Path(__file__).resolve().parent.parent def sha(text: str) -> str: return hashlib.sha256(text.encode()).hexdigest()[:12] def sources_hash(paths): h = hashlib.sha256() for p in sorted(paths): h.update(p.encode()) h.update((ROOT / p).read_bytes()) return h.hexdigest()[:12] def call_model(prompt: str) -> str: """Wire your provider here; this is the single place a model is called.""" raise NotImplementedError("point this at your generation endpoint") def main(): ap = argparse.ArgumentParser() ap.add_argument("block") args = ap.parse_args() spec = json.loads((ROOT / "docs/manifest.json").read_text())["blocks"][args.block] if spec["owner"] != "model": sys.exit(f"{args.block} is human-owned; edit {spec['overlay']} instead") prompt = (ROOT / spec["generator"]).read_text() for p in spec["sources"]: prompt += f"\n\n--- file: {p} ---\n" + (ROOT / p).read_text() body = call_model(prompt).strip() header = ( f"<!-- generated: block={args.block} sources_hash={sources_hash(spec['sources'])} " f"body_sha={sha(body)} generator={spec['generator']} -->" ) out = ROOT / "gen" / f"{args.block}.md" out.parent.mkdir(parents=True, exist_ok=True) out.write_text(header + "\n" + body + "\n") print(f"wrote {out.relative_to(ROOT)} ({len(body)} bytes)") if __name__ == "__main__": main() Generation order matters less than regeneration frequency, so run this per block rather than per page. When one endpoint changes, you regenerate one block and the diff stays reviewable.
Step 4 — Merge layers in a build step, never by hand
The build reads the manifest, strips the provenance header from generated blocks, and concatenates blocks per page with a marker line. Those markers are what make a merged page diffable, because reviewers can see which block a changed paragraph belongs to.
#!/usr/bin/env python3 """Assemble pages from generated blocks and human overlays.""" import json, pathlib, re, sys ROOT = pathlib.Path(__file__).resolve().parent.parent MANIFEST = json.loads((ROOT / "docs/manifest.json").read_text())["blocks"] HEADER = re.compile(r"^<!-- generated: block=\S+ .* -->$") pages: dict[str, list[str]] = {} for block, spec in MANIFEST.items(): if spec["owner"] == "human": text = (ROOT / spec["overlay"]).read_text().strip() else: header, _, body = (ROOT / "gen" / f"{block}.md").read_text().partition("\n") if not HEADER.match(header): sys.exit(f"{block}: missing provenance header, run tools/check_docs.py") text = body.strip() pages.setdefault(spec["page"], []).append(f"<!-- block: {block} -->\n{text}\n") for page, chunks in pages.items(): out = ROOT / "build" / page out.parent.mkdir(parents=True, exist_ok=True) out.write_text("\n".join(chunks)) print(f"{page}: {len(chunks)} blocks") Treat build/ as disposable output even if you commit it for search indexing. If a reviewer needs a wording change, the change belongs in an overlay file or in the generator instruction, never in the built page.
Step 5 — Fail CI on hand edits, staleness, and orphans
The check script is the part that actually holds the line, and it should run on every pull request. It reports three distinct problems so that the fix is obvious from the log.
#!/usr/bin/env python3 """Verify authorship boundaries and freshness for every doc block.""" import hashlib, json, pathlib, re, sys ROOT = pathlib.Path(__file__).resolve().parent.parent MANIFEST = json.loads((ROOT / "docs/manifest.json").read_text())["blocks"] HEADER = re.compile( r"^<!-- generated: block=(?P<block>\S+) sources_hash=(?P<src>[0-9a-f]+) " r"body_sha=(?P<body>[0-9a-f]+) generator=(?P<gen>\S+) -->$" ) def sha(text: str) -> str: return hashlib.sha256(text.encode()).hexdigest()[:12] def sources_hash(paths): h = hashlib.sha256() for p in sorted(paths): h.update(p.encode()) h.update((ROOT / p).read_bytes()) return h.hexdigest()[:12] errors = [] for block, spec in MANIFEST.items(): if spec["owner"] == "human": path = ROOT / spec["overlay"] if not path.exists() or not path.read_text().strip(): errors.append(f"{block}: missing or empty overlay {spec['overlay']}") continue path = ROOT / "gen" / f"{block}.md" if not path.exists(): errors.append(f"{block}: never generated") continue header, _, body = path.read_text().partition("\n") match = HEADER.match(header) if not match: errors.append(f"{block}: unparseable provenance header") continue if sha(body.rstrip()) != match.group("body"): errors.append(f"{block}: body hand-edited in gen/, edit the overlay or rerun generator") if sources_hash(spec["sources"]) != match.group("src"): errors.append(f"{block}: sources changed, rerun tools/gen_block.py {block}") for path in (ROOT / "gen").rglob("*.md"): if path.relative_to(ROOT / "gen").with_suffix("").as_posix() not in MANIFEST: errors.append(f"orphan generated block: {path.relative_to(ROOT)}") print("\n".join(errors) if errors else f"ok: {len(MANIFEST)} blocks verified") sys.exit(1 if errors else 0) Run it locally before pushing, because the failure messages are written for the person who caused them. A stale-source message tells you to regenerate one block, while a hand-edit message tells you the change belongs somewhere else entirely.
Three symptoms and the first thing to check
Symptom-driven debugging saves more time than reading the scripts twice. Each row below maps an observed problem to one command.
| Symptom | Likely cause | First action |
|---|---|---|
Build fails with sources changed | Source file edited without regeneration | python tools/gen_block.py <block> |
| Docs disagree with a schema that looks correct | Hand fix made inside gen/ | python tools/check_docs.py, then move the fix to an overlay |
| Page order looks scrambled | Manifest order changed during a merge conflict | Re-read docs/manifest.json diff before regenerating |
| A block is missing from the page | Block listed in manifest but never generated | python tools/gen_block.py <block> |
Where a free model and a free server fit
The generator needs a model endpoint, and the build needs somewhere to run on a schedule. MonkeyCode offers free model access and a free server option, which is enough to run tools/gen_block.py and tools/build_docs.py on the same machine without a paid pilot. Disclosure: This article was prepared as part of MonkeyCode's product outreach.
Keep the provider behind the single call_model function so the workflow survives a change of endpoint. The honest caveat is that free tiers change, so verify current limits yourself before you wire this into a release process you depend on.
Limitations and who should skip this
This build verifies freshness and authorship boundaries; it does not verify that generated prose is correct. A block can pass every hash check while describing the wrong default value, so schema-derived tables still deserve a skim during review. The mechanism also fragments pages, because a block boundary interrupts narrative flow.
Skip the whole approach if you maintain a single README that changes twice a year, since two extra scripts cost more attention than they save. Skip it as well if your documentation has no machine-readable inputs, because every block would then be human-owned and the generator would have nothing to do. Teams that cannot add a CI step should stay with ordinary review until they can.
Start with one page and three blocks: one derived table, one changelog summary, one owned policy section. Once the check script has failed a real pull request for the right reason, expand the manifest instead of the rules.
