The scene below is a composite of a pattern I keep seeing in documentation review threads, not a transcript from one specific repository.
A pull request touches docs/quickstart.md. The diff is clean. The prose reads well. Three reviewers approve in nine minutes, because the words are good and nobody wants to be the person who blocks a docs PR over "feel." Six weeks later a support engineer finds the page claiming a minimum runtime version that was bumped two releases ago. The value was never wrong at review time. It was unowned — nobody could say who was supposed to be accountable for that specific line.
That is the failure this article tries to fix, with a small file you keep in the repo.
Why "review harder" does not scale
Documentation review has an asymmetry problem. A reviewer reads prose linearly; accountability is not linear. In a 400-word section, maybe fifteen words carry a commitment — a version number, a region list, a rate limit, a security caveat. The other 385 words are scaffolding.
- Reviewers spend effort proportional to length.
- Risk is concentrated in a handful of fields.
- Model-drafted text moves the length up and leaves the fields exactly where they were — unmarked.
So the interesting question is not "may the model write docs?" It is: which regions of a document have an owner, and does the pipeline know where they are?
Two lanes, one file
Borrow the shape of a dependency lockfile. A lockfile does not forbid upgrades; it pins what has been agreed. A prose lockfile does the same for authorship.
Every documentation section gets one of two lanes:
- Draftable lane — the model may write and rewrite freely. Wording, ordering, examples, symptom descriptions. A human reviews for accuracy but is not required to author.
- Owned lane — the model may not modify the region at all. A human must author it, and a commit that touches it must carry an explicit attestation.
Default is owned. Drafting is opt-in per section. That inversion is the whole design: it is much easier to justify opening a section than to notice that one should have been closed.
The decision table
Use this as a starting point, not a policy. The middle column is the model's edit surface.
| Section content | Model may draft | Human must own |
|---|---|---|
| Install steps, command syntax | Yes | Shell flags that differ by platform |
| Troubleshooting symptoms and ordering | Yes | Error codes and root causes |
| API parameter descriptions | Yes, from the schema | Defaults, ranges, deprecation dates |
| Release highlights | Yes, from merged PR titles | Breaking-change list |
| Pricing, quotas, licensing | No | Everything |
| Security and privacy notes | No | Everything |
| Migration and rollback steps | Draft only | Ordering guarantees and data-loss warnings |
If your team argues about one row for more than ten minutes, that row belongs in the owned lane. Ambiguity is itself a signal.
The lockfile format
One YAML file per documentation tree, keyed by file path, then by exact heading string.
# docs.ownership.yml
version: 1
defaults:
draftable: false # deny by default
model_may: []
docs:
docs/quickstart.md:
sections:
"## Install":
draftable: true
model_may: [steps, shell-snippets, prose]
human_owned: [supported_platforms, minimum_version]
"## Pricing":
draftable: false
human_owned: ["*"]
reason: "Commercial claim; needs finance sign-off"
"## Troubleshooting":
draftable: true
model_may: [symptom-wording, ordering]
human_owned: [error_codes, root_cause]
Two things are deliberately boring here. Headings are matched as exact strings, which is fragile but greppable and reviewable in a diff. And draftable: false carries a reason, because a lock entry nobody can explain gets deleted during the next cleanup sprint.
Enforcing it in CI
The rule to enforce is narrow and mechanical:
A commit tagged as model-drafted may not change lines inside a section that is not in the draftable lane, unless the commit also carries a human attestation trailer.
Reference implementation (not run against a production repo in this article)
#!/usr/bin/env python3
"""owncheck.py - block model-drafted commits from editing owned doc regions."""
import pathlib, re, subprocess, sys
import yaml
MODEL = re.compile(r"^Drafted-by:\s*model\s*$", re.M)
HUMAN = re.compile(r"^Attested-by:\s*(\S+)", re.M)
HUNK = re.compile(r"^@@ -\d+(?:,\d+)? \+(\d+)(?:,(\d+))? @@")
def sections(path):
lines = pathlib.Path(path).read_text(encoding="utf-8").splitlines()
marks = [i for i, l in enumerate(lines) if l.startswith("#")]
out = []
for n, start in enumerate(marks):
end = marks[n + 1] - 1 if n + 1 < len(marks) else len(lines) - 1
out.append((lines[start].strip(), start, end))
return out
def changed(path, base):
diff = subprocess.run(
["git", "diff", "-U0", base, "--", path],
capture_output=True, text=True, check=True).stdout
hits = set()
for line in diff.splitlines():
m = HUNK.match(line)
if m:
start, count = int(m.group(1)), int(m.group(2) or 1)
hits.update(range(start - 1, start - 1 + count))
return hits
def main(base):
msg = subprocess.run(["git", "log", "-1", "--format=%B"],
capture_output=True, text=True).stdout
if not MODEL.search(msg) or HUMAN.search(msg):
return 0
spec = yaml.safe_load(open("docs.ownership.yml", encoding="utf-8"))
bad = []
for path, doc in (spec.get("docs") or {}).items():
if not pathlib.Path(path).exists():
continue
rules = doc.get("sections") or {}
for heading, start, end in sections(path):
rule = rules.get(heading) or {}
if rule.get("draftable"):
continue
touched = changed(path, base) & set(range(start, end + 1))
if touched:
bad.append(f"{path}:{min(touched) + 1} {heading}")
for item in bad:
print(f"OWNED REGION TOUCHED {item}")
return 1 if bad else 0
if __name__ == "__main__":
sys.exit(main(sys.argv[1] if len(sys.argv) > 1 else "origin/main"))
Wire it into the docs job. Two lines, nothing exotic:
- run: pip install pyyaml
- run: python tools/owncheck.py "origin/${{ github.base_ref }}"
And locally, so people feel it before CI does:
printf '#!/bin/sh\nexec python tools/owncheck.py origin/main\n' > .git/hooks/pre-commit
chmod +x .git/hooks/pre-commit
git commit -m "docs: tighten quickstart" -m "Attested-by: @morgan"
The Attested-by: trailer is the escape hatch, and it is deliberately visible. A human typing their handle next to owned prose is a claim of responsibility, not a rubber stamp — which is exactly what an audit later needs to find.
Where the drafting half runs
A checker without a drafter is just a lockfile nobody asked for; a drafter without a checker is the opening scene of this article. You need both, and the drafting half is the part that costs money if you run it constantly.
This is where I actually used MonkeyCode: pointed the free model access at the draftable lane for a docs tree, and kept the checker as the only thing with merge authority. Free model access covers the repetitive work — turning a merged-schema diff into parameter descriptions, turning PR titles into release highlights, re-wording a troubleshooting section without touching the error codes. The free server option is the other useful half: a small always-on box is enough for a nightly job that re-runs the drafter on sections the lockfile marks draftable and opens a PR with the diff, so nobody waits on a laptop build.
Disclosure: This article was prepared as part of MonkeyCode's product outreach.
The operator of this account states a free allowance of 10 million tokens and a free server option; verify the current terms on the project page before you plan capacity around them, because free-tier terms are the kind of thing that changes. The open-source project is at the MonkeyCode repository — read the license and the issue tracker before adopting it, the same way you would for any dependency that touches your build.
The architecture rule that matters: the model never gets commit rights to owned regions, no matter how good the draft looks. The lockfile is the boundary, CI is the enforcement, and the free tier only makes the drafting half cheap enough to run on every push instead of once a quarter.
Failure modes worth knowing before you ship this
-
Heading renames silently unlock sections. A
## Pricingrenamed to## Plansmatches no rule, hits the defaultdraftable: false, and blocks model commits — annoying but safe. The dangerous direction is the opposite: adding a defaultdraftable: trueto silence the noise. -
Merge conflicts scramble line ranges. Run the check against
base...HEADon the merge result, not on each side separately. - Draftable sections can still be confidently wrong. The lane says "a human reviews this," not "this is verified." Keep a claim-level gate for numbers if you have one.
-
Trailers are easy to forge.
Attested-by:is an accountability record, not authentication. If you need real identity binding, sign commits. -
The lockfile rots. Sections get added and never registered. Add a CI assertion that every
##heading in a tracked file appears in the lockfile, even if only asdraftable: false.
Who should not use this
Skip it if you maintain fewer than about five documentation pages — the YAML will cost more than the risk. Skip it for legal, medical, or regulated compliance text where every sentence needs a qualified sign-off; a lane system implies some sentences need none, and there that implication is wrong. And skip it if your team has no CI gate on docs at all, because a lockfile that nothing checks is just a comment with extra syntax.
Rollout, in the order that causes the least argument
- Generate the lockfile from existing headings, all entries
draftable: false. Ship that PR alone; nobody's workflow changes and the file gets reviewed once. - Open the three sections where drafting clearly helps — install, troubleshooting, release notes. Measure review time on those PRs for a month.
- Add the CI job. Expect the first week to be noisy; fix rules, not the checker.
- Move the drafter onto the free model access and schedule the nightly PR job on the free server.
- Re-read the decision table quarterly. Sections migrate between lanes as your product changes, and that migration is the useful artifact — it is a written record of what your team decided it is accountable for.
If you try one piece of this, make it step one. A prose lockfile with every section closed is a complete inventory of what your docs owe, and that inventory is worth having whether or not a model ever writes a line of it.
Top comments (0)