A documentation draft is only reviewable when each factual sentence has a named owner who can be wrong about it. Most generated-docs pipelines enforce style, tone, and length, then hand the whole file to whoever happens to be on call. That reviewer cannot verify forty unrelated claims across three subsystems, so the review becomes a skim and the numbers drift. The gate below takes a different position: the model drafts prose freely, but every sentence that carries a fact must point at the code that produced it, and that pointer must resolve to a human or the build fails.
This is an ownership routing problem, not a writing problem. If you want a style linter, this article will not help you; if you want a reviewer with a defensible reason to approve a paragraph, keep reading.
The division of labor, stated as a table
Before writing any checker, decide in writing what the model is allowed to produce. Teams that skip this step end up arguing about individual sentences in review, which is the slowest possible feedback loop.
| Artifact | Who drafts | Who owns the final wording |
|---|---|---|
| Transitions, intros, section ordering | Model | Docs reviewer |
| Restating a function signature already in the repo | Model | Docs reviewer |
| Error explanations that quote an existing message string | Model | Docs reviewer |
| Retry counts, timeouts, size limits, default values | Model copies from source | Owner of the source file |
| Deprecation dates, support windows, pricing, SLAs | Nobody | Product or legal, never model-generated |
| Security posture, data retention, compliance statements | Nobody | Security owner, never model-generated |
| Migration steps across a breaking change | Model proposes | Owner of the changed API |
The right column is the important one, because it names a person or a team rather than a role. A paragraph about rate limits belongs to whoever owns the limiter, and that mapping already exists in your repository as .github/CODEOWNERS plus commit history.
Step 1: force facts to carry a pointer at draft time
Require the drafting prompt to emit an HTML comment immediately before any sentence that states a verifiable value. The marker is invisible in rendered Markdown and cheap for a model to produce consistently.
<!-- fact: src/limiter.ts:MAX_RETRIES -->
Requests are retried up to three times before the client receives an error.
<!-- fact: src/limiter.ts -->
The limiter resets its window on every successful call.
The optional symbol after the colon narrows the pointer to an identifier inside the file. Symbol-level pointers give you staleness detection later; file-level pointers only give you ownership. Ask for symbol-level whenever the fact is a constant, and accept file-level for behavioral descriptions.
Step 2: resolve each pointer to an owner
The resolver reads CODEOWNERS with GitHub's last-match-wins precedence, then falls back to the most recent commit author for that path. The fallback matters because plenty of real repositories have files that no pattern covers, and "nobody" is a worse answer than "the person who last touched it".
#!/usr/bin/env python3
"""route_owners.py - provenance ownership gate for model-drafted docs."""
import fnmatch
import re
import subprocess
import sys
from pathlib import Path
FACT = re.compile(
r"<!--\s*fact:\s*(?P<path>[^\s:>]+)(?::(?P<symbol>[A-Za-z0-9_]+))?\s*-->"
)
CODEOWNER_FILES = (".github/CODEOWNERS", "CODEOWNERS", "docs/CODEOWNERS")
def load_codeowners(root: Path):
for rel in CODEOWNER_FILES:
candidate = root / rel
if candidate.is_file():
rules = []
for line in candidate.read_text(encoding="utf-8").splitlines():
line = line.split("#", 1)[0].strip()
if not line:
continue
parts = line.split()
pattern = parts[0].lstrip("/").rstrip("/") or "*"
rules.append((pattern, parts[1:]))
return rules
return []
def owner_for(path: str, rules):
chosen = None
for pattern, owners in rules: # GitHub semantics: last match wins
if fnmatch.fnmatch(path, pattern) or fnmatch.fnmatch(Path(path).name, pattern):
chosen = owners
return chosen or []
def git_author(root: Path, path: str):
proc = subprocess.run(
["git", "-C", str(root), "log", "-1", "--format=%ae", "--", path],
capture_output=True,
text=True,
)
if proc.returncode != 0:
return None
return proc.stdout.strip() or None
def symbol_present(root: Path, path: str, symbol) -> bool:
if not symbol:
return True
source = root / path
if not source.is_file():
return False
return symbol in source.read_text(encoding="utf-8", errors="ignore")
def blocks(draft: str):
result, pending, buffer = [], [], []
for line in draft.splitlines():
hits = FACT.findall(line)
rest = FACT.sub("", line).strip()
if hits and not rest:
pending.extend(hits)
continue
if not rest:
if buffer:
result.append((" ".join(buffer), pending))
buffer, pending = [], []
continue
buffer.append(rest)
pending.extend(hits)
if buffer:
result.append((" ".join(buffer), pending))
return result
def main(root: Path, draft_path: Path) -> int:
rules = load_codeowners(root)
failures, rows = 0, []
text = draft_path.read_text(encoding="utf-8")
for index, (block, facts) in enumerate(blocks(text), start=1):
if block.startswith("#"):
continue
if not facts:
if re.search(r"\d", block): # a number with no provenance
failures += 1
rows.append((index, "-", "UNMARKED", block[:60]))
continue
for path, symbol in facts:
owners = owner_for(path, rules)
if not owners:
fallback = git_author(root, path)
owners = [fallback] if fallback else []
if not owners:
status = "UNOWNED"
elif not symbol_present(root, path, symbol):
status = "STALE"
else:
status = "OK"
if status != "OK":
failures += 1
rows.append((index, f"{path}:{symbol or '*'}", f"{status} {' '.join(owners)}", block[:60]))
print("| # | fact | status / owner | sentence |")
print("|---|---|---|---|")
for row in rows:
print(f"| {row[0]} | `{row[1]}` | {row[2]} | {row[3]}... |")
return 1 if failures else 0
if __name__ == "__main__":
sys.exit(main(Path(sys.argv[1]), Path(sys.argv[2])))
The script prints a routing table and exits non-zero on any problem, so it works both as a human briefing and as a CI gate.
Step 3: read the three failure statuses correctly
UNOWNED means the pointer resolved to nothing, which is almost always a wrong path in the marker rather than a missing team. Fix the path first, and only add a CODEOWNERS rule when the path is genuinely legitimate. STALE means the file exists but no longer contains the symbol, which is the cheapest possible signal that a documented constant was renamed or removed. UNMARKED means a paragraph contains a digit and no provenance marker, and that rule is what stops the model from quietly inventing a plausible timeout value.
The staleness check is a substring test, so it can produce false negatives when a symbol name appears in a comment for unrelated reasons. Treat it as a smoke alarm, not a proof: it catches renames, and it will not catch a changed value behind an unchanged name.
Step 4: run it where the drafts are produced
Drafting in bulk only makes economic sense when generation is cheap, which is the case with MonkeyCode's free model access, and the routing job is small enough to sit next to that loop on the free server option rather than on a laptop. Disclosure: This article was prepared as part of MonkeyCode's product outreach. The point of running the gate near the generator is latency: a draft that fails ownership routing should never reach a pull request, because a reviewer who sees an unowned paragraph learns to ignore the table entirely.
# .github/workflows/docs-ownership.yml
name: docs-ownership
on:
pull_request:
paths: ["docs/**/*.md"]
jobs:
route:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0 # git-log fallback needs history
- run: python3 tools/route_owners.py . docs/limits.md
fetch-depth: 0 is required, because a shallow clone makes the commit-author fallback return nothing and silently converts owned files into UNOWNED.
Verification: build negative fixtures before trusting the gate
Create three fixture drafts and assert the exit codes, since a gate that has only ever passed has demonstrated nothing about its behavior. An owned fixture should exit 0 with an OK row, a fixture pointing at src/missing.ts should exit 1 with UNOWNED, and a fixture pointing at a deleted symbol should exit 1 with STALE. Add a fourth fixture with a bare number and no marker to confirm UNMARKED fires. Running these four cases locally takes seconds and gives you a defensible claim that the checker works.
Limitations and who should skip this
This approach is a poor fit for repositories where documentation intentionally describes behavior no single file owns, such as conceptual overviews or cross-service architecture narratives. It is also wrong for teams that cannot name owners at all, because the gate will simply produce a wall of UNOWNED rows and get disabled within a week. Single-maintainer projects should skip the CODEOWNERS layer and rely on the git-log fallback alone, which is close to zero configuration. Finally, the fnmatch matching in this script is an approximation of GitHub's path semantics, so translate patterns conservatively and prefer literal directory prefixes over clever globs.
The rule worth keeping is the one that survives tool changes: the model may draft sentences, and a human must own every fact in them. Provenance markers make that rule executable instead of aspirational, and the routing table turns a vague review request into a short list of people who can actually be wrong.
Top comments (0)