The expensive question in model-drafted documentation is not whether the prose reads well; it is which paragraphs a named human is accountable for after merge. If you cannot answer that from the pull request alone, review turns into archaeology and nobody signs anything. This workflow answers it mechanically: three lanes, a lane map keyed by section, and a diff check that refuses to let a model touch accountability-bearing text before a reviewer opens the page.
The failure this fixes
Consider an illustrative scenario rather than a reported incident: a nightly job drafts prose for docs/install.md, and its diff quietly rewrites the sentence describing which platforms are supported. The prose is grammatical, the CI is green, and the reviewer skims forty changed lines because the title says "docs cleanup". Two weeks later support learns the page promises something the code does not, and blame lands on whoever merged it. Nothing here happened to me; it is the shape of the failure, not a record of one.
The root cause is not model quality, because better prose produces the same ambiguity. The root cause is that a doc file has one review state, while its paragraphs have very different accountability requirements. A tagline and a supported-platforms statement arrive in the same diff and get the same scrutiny. Fix the granularity first and the prose quality question becomes secondary.
Three lanes, one decision each
Every section gets exactly one lane, and the lane decides who may produce text and what the reviewer must check.
| Lane | Model may produce text | Required in the diff | Reviewer checks |
|---|---|---|---|
draft |
Yes, freely | Nothing beyond normal formatting | Accuracy of tone and examples only |
cited |
Yes, with a source per claim block | A line starting with Source: pointing at a repo path |
That the cited file actually says it |
human |
No, placeholder only | A named owner and a review comment | The whole statement, from scratch |
The useful property is that the lane map is a small reviewable file, so arguments about ownership happen once, in a pull request about the map, and not repeatedly inside prose diffs. The lanes are not a quality ranking; human is not better than draft, it simply carries different consequences.
Step 1: Write the lane map
The map lives beside the docs and is keyed by <file>#<section heading>, using the leaf heading text so keys stay short and readable.
# docs/ownership.yaml
version: 1
budget:
draft_lane_added_lines: 120
sections:
"docs/install.md#Prerequisites":
lane: cited
owner: "@avery"
"docs/install.md#Supported platforms":
lane: human
owner: "@avery"
"docs/install.md#Why this exists":
lane: draft
owner: "@avery"
Start by laning only the file you change most often, because a partial map with a hard failure on unmapped sections is more useful than a complete map nobody finished. Unmapped sections fail closed, which forces the map to grow exactly where diffs actually land. The owner field is documentation for humans; the checker only enforces lane, so do not treat ownership as automated.
Step 2: Classify added lines by section
The checker reads the diff at zero context, maps each added line to the nearest preceding heading in the new file, then groups by section. The script below is illustrative and has not been executed as part of this article.
#!/usr/bin/env python3
"""Illustrative lane checker. Not executed or tested here."""
import re, subprocess, yaml
HEADING = re.compile(r"^(#{1,6})\s+(.*)$")
def sections_of(path):
out, stack = [], []
with open(path, encoding="utf-8") as fh:
for i, line in enumerate(fh, start=1):
m = HEADING.match(line.rstrip("\n"))
if m:
stack = stack[: len(m.group(1)) - 1] + [m.group(2).strip()]
out.append((i, " > ".join(stack)))
return out
def section_for(sections, lineno):
current = "(top)"
for start, path in sections:
if start <= lineno:
current = path
else:
break
return current
def added_lines(base):
diff = subprocess.run(
["git", "diff", "--unified=0", "--no-color", f"{base}...HEAD", "--", "docs/"],
capture_output=True, text=True, check=True).stdout
path, lineno, rows = None, 0, []
for line in diff.splitlines():
if line.startswith("+++ b/"):
path = line[6:]
elif line.startswith("@@"):
lineno = int(re.search(r"\+(\d+)", line).group(1))
elif line.startswith("+") and path:
rows.append((path, lineno, line[1:]))
lineno += 1
return rows
def main(base):
ledger = yaml.safe_load(open("docs/ownership.yaml", encoding="utf-8"))
lanes = ledger.get("sections", {})
budget = ledger.get("budget", {}).get("draft_lane_added_lines", 0)
cache, groups = {}, {}
for path, lineno, text in added_lines(base):
if path not in cache:
cache[path] = sections_of(path)
heading = section_for(cache[path], lineno)
key = f"{path}#{heading.split(' > ')[-1]}"
groups.setdefault(key, []).append(text)
problems, drafted = [], 0
for key, lines in groups.items():
entry = lanes.get(key)
if entry is None:
problems.append(f"{key}: section missing from lane map")
elif entry["lane"] == "human":
problems.append(f"{key}: {len(lines)} added line(s) in a human-owned section")
elif entry["lane"] == "cited" and not re.search(r"^\s*source:", "\n".join(lines), re.I | re.M):
problems.append(f"{key}: cited lane section has no line starting with `Source:`")
else:
drafted += len(lines)
if budget and drafted > budget:
problems.append(f"draft and cited lanes added {drafted} lines, budget is {budget}")
for p in problems:
print(f"lane-check: {p}")
return 1 if problems else 0
if __name__ == "__main__":
raise SystemExit(main("origin/main"))
Run it locally against your branch base before pushing, so the first failure is yours to read rather than a reviewer's.
python scripts/lane_check.py origin/main
Step 3: Wire it into CI with a budget
The check belongs in a job that fetches full history, because git diff base...HEAD needs a real merge base to be meaningful. A shallow checkout silently produces empty diffs, which fail open, which is worse than having no check at all.
name: doc-lanes
on: [pull_request]
jobs:
lanes:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- run: pip install pyyaml
- run: python scripts/lane_check.py "origin/${{ github.base_ref }}"
The draft_lane_added_lines budget is a policy number, not a measured threshold, and you should pick it from your team's actual review capacity. Its job is to convert "this docs PR is too big to read carefully" into a failing check that asks for a split. Treat the initial value as a guess to be adjusted after two weeks of real diffs.
Step 4: Give humans a visible override
Some edits are legitimate in a human lane without a model: fixing a broken link, correcting a stale path, repairing a typo in a support matrix. Banning those outright pushes people to weaken the map instead, so provide one override that leaves a trace in history.
Lane-Override: docs/install.md#Supported platforms
Lane-Override-Reason: fix dead link in existing sentence, no claim change
The checker reads the commit message, prints the override to the job log, and passes the section. Because the trailer lives in the commit, an override is auditable later without asking anyone to remember the context. Keep the reason field mandatory in review culture even if the script does not parse it.
Which content belongs in which lane
| Content type | Lane | Reason |
|---|---|---|
| Motivation, architecture narrative, tutorials | draft |
Wrong prose is annoying, not costly |
| Configuration steps, flags, code samples | cited |
Must trace back to source files |
| Support matrix, deprecation promises, security posture | human |
Commitments a company makes |
| Pricing, quotas, licence and legal text | human |
Regulated or contractual |
| Generated API references | excluded | Machine output, not hand-laned |
Where the drafting lane actually runs
The drafting lane needs a model endpoint you can point a script at, and a place to run dry checks when CI minutes are contested. MonkeyCode's free model access and free server option are relevant there, since the lane checker and a draft script are small workloads, and evaluating a workflow should not require a procurement conversation. Disclosure: This article was prepared as part of MonkeyCode's product outreach. Verify the current terms of any free offering directly before you build a pipeline around it, and keep the endpoint behind an environment variable so the pipeline stays portable.
Limitations and who should skip this
Provenance here is by convention, not cryptography: a model that commits directly to main with a human author field bypasses every lane, and only branch protection stops that. Heading-derived keys break when a heading is renamed, which is why the unmapped-section failure is deliberately loud rather than a warning. Duplicate leaf headings in one file collide on the same key, so either keep headings unique or extend the key to the full heading path. Nothing in this design checks whether a Source: line is accurate or current, because lanes allocate accountability and do not establish truth.
Skip this if you maintain docs alone with no external commitments, if your reviewers will not read a failing check, or if most of your pages are generated artifacts. In those cases a plain style guide plus honest commit messages will cost less than a lane map you stop maintaining after a month.
If you try the three-lane split, the most useful thing to share back is your lane decision table for that awkward middle case: configuration examples that also imply support.
Top comments (0)