Most generated documentation fails for an ownership reason, not a fluency reason. A model can produce a correct paragraph about retry behaviour and still leave the team unable to say who is accountable when that paragraph turns false. The workflow below answers that question with a file in the repository rather than a convention in somebody's head.
The mechanism has two lanes, one manifest, and six checks that finish in well under a second on a normal docs tree. Every doc sentence belongs either to the generated lane, where a build step re-derives the text from a source symbol, or to the human lane, where a named owner signs the claim and re-signs it before an expiry date. There is no third lane, and that missing third lane is what keeps the boundary honest.
1. The leak is in-place editing, not hallucination
When a reviewer finds a wrong sentence inside a generated block, the cheapest fix is to edit that block by hand. The edit looks correct until the next regeneration pass, which silently removes it, because the generator only knows how to reproduce its source. Nothing fails in that moment: the page still renders, the anchor still resolves, and the correction quietly disappears from the repository.
That is a provenance problem more than a writing problem. The block cannot be trusted because the file no longer records what produced it, and reviewers have no way to see that the text they approved was overwritten. Making the boundary machine-checkable costs far less than re-reviewing an entire page on every release.
2. Sort sentence classes before you sort files
Most teams organize doc ownership by directory, which is the wrong axis. A single page about batch limits mixes numbers that come from a constant with promises that come from a product decision. Sort the sentences first, then let the file layout follow the classification.
| Sentence class | Lane | Who signs | Example |
|---|---|---|---|
| Numeric default, cap, timeout, page size | generated | nobody; a source pointer |
MAX_BATCH is 500 |
| Enum values, exit codes, config keys | generated | nobody; a source pointer |
--retries accepts 0 to 10 |
| Behaviour under load or failure | human | service owner | requests above the cap fail with 429
|
| Support window and deprecation dates | human | product owner | 1.4 stops receiving fixes in March 2027 |
| Migration steps and rollback | human | release engineer | drain the ingest queue before upgrading |
| Security and guarantee language | human | security reviewer | payloads are encrypted at rest |
| Cost, latency, and scaling expectations | human | product owner | p99 stays under 300 ms to 10k rows |
Apply one rule while classifying: if a sentence cannot be re-derived from a symbol in the repository, it does not belong in the generated lane, no matter how confident the model sounds. That rule moves most disputed prose into the human lane, where it acquires a name, a reason, and an expiry date instead of an argument.
3. The manifest: one section per lane, with an expiry
The manifest is a small TOML file mapping each section to a lane and to an accountable person. Generated sections point at a symbol, while human sections carry an owner, a review date, and a reason long enough to be meaningful.
# docs/owners.toml
max_generated_share = 0.70
[files.'docs/limits.md']
[[files.'docs/limits.md'.sections]]
id = 'batch-cap'
lane = 'generated'
source = 'src/limits.py:MAX_BATCH'
[[files.'docs/limits.md'.sections]]
id = 'overflow-semantics'
lane = 'human'
owner = 'avery'
review_by = '2026-12-01'
watches = ['src/limits.py', 'src/queue/*.py']
reason = 'Whether we fail fast or spill to a second call is a product decision with billing impact.'
The watches globs matter later, because they declare which code changes invalidate a human review. The expiry date is deliberately short-lived: a review date two years in the future is indistinguishable from no review at all.
The page itself carries one marker pair per declared section, and the regions between the markers are the only places a generator may write.
# Batch limits
<!-- docgate:batch-cap -->
`MAX_BATCH` is 500 records per call.
<!-- /docgate:batch-cap -->
<!-- docgate:overflow-semantics -->
Requests above the cap fail with `429`; the service does not spill to a second
call. Retry once the ingest queue drains.
<!-- /docgate:overflow-semantics -->
4. The gate: six checks in one stdlib-only script
The script below uses only the standard library, including tomllib for the manifest and ast for symbol resolution, so it runs in a pre-commit hook without installing anything.
#!/usr/bin/env python3
'''docgate: keep generated doc facts and signed human claims in separate lanes.'''
from __future__ import annotations
import ast
import datetime as dt
import fnmatch
import re
import subprocess
import sys
import tomllib
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
MANIFEST = ROOT / 'docs' / 'owners.toml'
MARKER = re.compile(r'<!-- docgate:(?P<id>[a-z0-9-]+) -->(?P<body>.*?)<!-- /docgate:(?P=id) -->', re.S)
PLACEHOLDERS = ('tbd', 'todo', 'fixme', '???')
def module_names(path: Path) -> set[str]:
tree = ast.parse(path.read_text(encoding='utf-8'))
names: set[str] = set()
for node in tree.body:
if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef, ast.ClassDef)):
names.add(node.name)
elif isinstance(node, ast.Assign):
names |= {t.id for t in node.targets if isinstance(t, ast.Name)}
elif isinstance(node, ast.AnnAssign) and isinstance(node.target, ast.Name):
names.add(node.target.id)
return names
def source_resolves(spec: str) -> bool:
path, _, symbol = spec.partition(':')
target = ROOT / path
return target.exists() and (not symbol or symbol in module_names(target))
def changed_since(iso_date: str) -> set[str]:
done = subprocess.run(
['git', '-C', str(ROOT), 'log', '--since=' + iso_date, '--name-only', '--pretty=format:'],
capture_output=True, text=True, check=False)
return {line.strip() for line in done.stdout.splitlines() if line.strip()}
def load_manifest() -> dict:
return tomllib.loads(MANIFEST.read_text(encoding='utf-8'))
def check(today: dt.date) -> list[str]:
manifest = load_manifest()
budget = manifest.get('max_generated_share', 1.0)
problems: list[str] = []
for doc_rel, doc in manifest['files'].items():
text = (ROOT / doc_rel).read_text(encoding='utf-8')
blocks = {m.group('id'): m.group('body') for m in MARKER.finditer(text)}
declared = {s['id']: s for s in doc.get('sections', [])}
for missing in sorted(declared.keys() - blocks.keys()):
problems.append('{0}: {1} declared but not marked'.format(doc_rel, missing))
for extra in sorted(blocks.keys() - declared.keys()):
problems.append('{0}: marker {1} absent from the manifest'.format(doc_rel, extra))
for sid, section in declared.items():
body = blocks.get(sid, '')
if section['lane'] == 'generated' and not source_resolves(section['source']):
problems.append('{0}#{1}: source {2} does not resolve'.format(doc_rel, sid, section['source']))
if section['lane'] == 'human':
review_by = dt.date.fromisoformat(section['review_by'])
if review_by < today:
problems.append('{0}#{1}: review expired {2}'.format(doc_rel, sid, review_by))
if len(section['reason'].split()) < 6:
problems.append('{0}#{1}: reason needs at least six words'.format(doc_rel, sid))
lowered = body.lower()
if any(token in lowered for token in PLACEHOLDERS):
problems.append('{0}#{1}: placeholder in a human-owned section'.format(doc_rel, sid))
generated = sum(len(blocks[s['id']].splitlines()) for s in declared.values()
if s['lane'] == 'generated' and s['id'] in blocks)
total = len([line for line in text.splitlines() if line.strip()])
share = generated / total if total else 0.0
if share > budget:
problems.append('{0}: generated share {1:.0%} over budget {2:.0%}'.format(doc_rel, share, budget))
return problems
def queue(today: dt.date, horizon: int = 30) -> list[tuple[str, int, int]]:
rows: list[tuple[str, int, int]] = []
for doc_rel, doc in load_manifest()['files'].items():
for section in doc.get('sections', []):
if section['lane'] != 'human':
continue
review_by = dt.date.fromisoformat(section['review_by'])
days = (review_by - today).days
if days > horizon:
continue
changed = changed_since(review_by.isoformat())
churn = len({f for f in changed
if any(fnmatch.fnmatch(f, g) for g in section.get('watches', []))})
rows.append(('{0}#{1}'.format(doc_rel, section['id']), days, churn))
rows.sort(key=lambda row: (row[1], -row[2]))
return rows
def main(argv: list[str]) -> int:
today = dt.date.today()
command = argv[1] if len(argv) > 1 else 'check'
if command == 'check':
problems = check(today)
for problem in problems:
print('FAIL ' + problem)
print('{0} problem(s)'.format(len(problems)))
return 1 if problems else 0
if command == 'queue':
for name, days, churn in queue(today):
state = 'EXPIRED' if days < 0 else '{0}d left'.format(days)
print('{0:>9} churn={1:<3} {2}'.format(state, churn, name))
return 0
print('usage: docgate.py [check|queue]', file=sys.stderr)
return 2
if __name__ == '__main__':
raise SystemExit(main(sys.argv))
Read the checks as a contract rather than a style guide:
- Every declared section has a marker, and every marker appears in the manifest.
- Each generated section resolves to a module-level symbol through
ast. - Each human section has an owner, a future review date, and a reason of at least six words.
- Placeholder tokens such as
tbdorfixmenever ship inside a human-owned section. - The generated share of a page stays under
max_generated_share, which flags pages drifting into unmaintainable machine mass. - The
queuecommand ranks human sections by days remaining and by code churn since the last review.
The churn number is the useful one, because a section whose watched files changed six times since its review date is almost certainly describing old behaviour.
A queue run prints one row per human section due soon. The shape of the output, on an illustrative fixture rather than a measured benchmark, looks like this:
$ python tools/docgate.py queue
45d left churn=6 docs/limits.md#overflow-semantics
12d left churn=0 docs/auth.md#token-rotation
EXPIRED churn=3 docs/migration.md#downgrade-path
5. Run it as a workflow, not a wall of red
Adding a blocking check to a docs pipeline on day one produces a pile of failures and a quiet decision to disable the job. Roll it out in this order.
- Classify one page using the table in section 2 and write down the disputes that the classification creates.
- Add marker pairs to that page without moving any text yet, so the diff stays reviewable.
- Write the manifest entry for every section, including a reason phrase you would defend in a review.
- Run
python tools/docgate.py checklocally in a pre-commit hook and fix only the new page. - Run
python tools/docgate.py queueweekly in a team channel, and treat any non-zero churn as a review trigger rather than a suggestion. - Add the CI job last, first as a report-only step for two weeks, then as a blocking step once the queue is empty.
6. Prove that generated blocks were not hand-edited
Structure checks cannot see a human edit that happens to preserve the markers. Git can, and one command is enough to audit a single block during code review.
git blame docs/limits.md | grep -A4 'docgate:batch-cap'
The author column should show the generator bot for every line inside the block. When a human name appears, either the fact belongs in the manifest as a human section, or the generator needs a better source symbol. Treat that audit as the tie-breaker whenever a reviewer feels tempted to patch generated prose in place.
7. Where a model actually helps
A model is useful for exactly one step in this workflow: producing a first draft of human-lane sections that a person then edits, signs, and dates. That draft arrives as a patch to review, never as a merged commit, and it never writes into the generated lane, because that lane is compiled from symbols.
Because that drafting pass is chatty and low-stakes, it rewards cheap model access and a small runner that stays available between reviews. MonkeyCode's free model access and free server option are the two availability claims this workflow leans on, and they are operator-supplied rather than measured here; no quota, hardware, model name, or latency figure is asserted.
Disclosure: This article was prepared as part of MonkeyCode's product outreach.
8. Limitations and who should not use this
- The gate verifies structure and provenance, not truth. A signed sentence can still be wrong, and only re-review fixes that.
- Symbol resolution covers module-level Python names. Re-exports, decorators, and generated stubs usually need a mapping file or a different resolver.
- Churn counting matches git paths against globs, so renames and monorepo moves silently stop matching. Re-check the globs after any large refactor.
- Expired reviews block merges by design, and teams without a doc owner will bump dates to make the red go away. Budget a named owner before you enable the blocking step.
- The generated share budget can be gamed by moving prose into a second file, so review the ratio per page rather than per repository.
- Skip this approach if a schema already generates your entire reference, if you are a solo maintainer who cannot justify a manifest, or if the text is compliance copy that a lawyer must sign outside your pipeline.
The measurement worth tracking after a month is not the pass rate of the check. It is how many human-owned sections expire within the next thirty days, because that number tells you how much of your documentation currently has no living owner.
Top comments (0)