A page has an owner only when a named human has committed five specific sentences about it, and everything else in the file is fair game for a model draft. The gate below enforces that split on every pull request, using a flat manifest, a human allow-list, and roughly eighty lines of standard-library Python. It is cheap to adopt, and it fails loudly in the one case that matters.
The failure this prevents
Reviewers read prose, not provenance, and a fluent paragraph does not carry a visible author. A confident description of retry semantics looks identical whether a backend engineer verified it against the worker or a model assembled it from three similar pages. Page-level review rules do not catch this, because each paragraph is short enough to skim and plausible enough to approve. The result is documentation that nobody can defend when a support ticket asks a hard question.
Section-level conventions have the same weakness, since a rule like "humans review the limits section" decays the moment a model rewrite moves the limits sentence one heading up. The anchors below are structural rather than editorial: they live in a checked-in manifest, they reference commits, and they can be validated without reading the prose at all.
The five anchors
Each anchor answers a question that the model is not in a position to settle, no matter how much repository context it receives.
| Anchor | Question it answers | Why a model cannot own it |
|---|---|---|
audience |
Who is this page written for, and what do they already know? | Requires knowing who actually files tickets |
job |
What single task should a reader finish after reading? | Requires a commitment about supported use |
decision |
What must a reader decide, and what is the default? | Requires a policy the team will stand behind |
non_goal |
What does this page deliberately not cover? | Requires the authority to decline scope |
failure_mode |
What breaks if the reader applies this wrongly? | Requires operational or incident knowledge |
These five are claims about scope and consequence rather than facts about the codebase. A model can restate what exists in a repository; it cannot promise what a team supports or refuse what it will not. The anchors are therefore the smallest set of sentences that turn a page into someone's responsibility.
Step 1 — Mark the regions a model may draft
Use an HTML comment so the marker is visible in source and invisible when the page renders. Keep the marker narrow, and let the anchors sit outside it.
# Queue retries
## Who this is for <!-- anchor:audience -->
## What you will do <!-- anchor:job -->
<!-- draft:model -->
The worker retries a failed job up to five times, then moves it to the dead-letter
queue. Each retry waits `2^n` seconds with full jitter applied before the delay.
<!-- /draft:model -->
## What this page does not cover <!-- anchor:non_goal -->
Only the <!-- draft:model --> token is load-bearing for the checker, and the anchor comments exist for human navigation and diff review. If you already use a different drafting marker, change one regular expression in the script.
Step 2 — Record the anchors in a flat manifest
Keep the manifest as JSON so the checker needs no third-party parser and can run in a minimal container.
{
"docs/queue-retries.md": {
"audience": {
"text": "Backend engineers who own a worker in production",
"by": "avery",
"commit": "9c1f4ab",
"verified_at": "2026-10-12"
},
"job": {
"text": "Configure retry counts without losing failed jobs",
"by": "sam",
"commit": "4d7e021",
"verified_at": "2026-10-09"
}
}
}
Three more entries follow the same shape, and commit points at the change that introduced or last re-verified the anchor text. That pointer is what makes the claim auditable later, when the page has been rewritten twice and nobody remembers who promised what.
Step 3 — Give drafting its own git identity
Configure the drafting tool to commit under a dedicated address that never appears in the human allow-list. This single convention is what prevents a model-authored commit from being used as evidence of human ownership.
# one-time, in the drafting environment
git config user.name "draft-bot"
git config user.email "draft-bot@example.invalid"
{
"avery": "avery@example.com",
"sam": "sam@example.com"
}
The allow-list lives in docs/people.json and should match real addresses used for signed commits, not display names. When someone leaves the team, remove them from the file and the gate will start naming every anchor they previously owned.
Step 4 — Run the gate in pre-commit and CI
The script below resolves each anchor commit, compares the author against the allow-list, confirms the commit actually touched the manifest, and checks anchor age.
#!/usr/bin/env python3
'''anchor_gate.py - fail when a model-drafted page has no human-owned anchors.'''
from __future__ import annotations
import datetime as dt
import json
import re
import subprocess
import sys
from pathlib import Path
REQUIRED = ('audience', 'job', 'decision', 'non_goal', 'failure_mode')
DRAFT_MARK = re.compile(r'<!--\s*draft:model\s*-->')
MAX_AGE_DAYS = 90
ROOT = Path(__file__).resolve().parent
def git(*args: str) -> str:
done = subprocess.run(['git', *args], cwd=ROOT, capture_output=True, text=True)
return done.stdout.strip()
def load(name: str) -> dict:
return json.loads((ROOT / name).read_text(encoding='utf-8'))
def drafted_pages() -> list[str]:
pages = []
for page in sorted(ROOT.glob('docs/**/*.md')):
if DRAFT_MARK.search(page.read_text(encoding='utf-8')):
pages.append(str(page.relative_to(ROOT)))
return pages
def check_page(page: str, manifest: dict, humans: dict) -> list[str]:
entry = manifest.get(page)
if entry is None:
return [f'FAIL {page}: drafted but missing from docs/anchors.json']
problems = []
for key in REQUIRED:
anchor = entry.get(key) or {}
text = (anchor.get('text') or '').strip()
by = anchor.get('by') or ''
sha = anchor.get('commit') or ''
if not (text and by and sha):
problems.append(f'FAIL {page} {key}: anchor incomplete')
continue
author = git('show', '-s', '--format=%ae', sha).lower()
if humans.get(by, '').lower() != author:
problems.append(
f'FAIL {page} {key}: commit {sha[:7]} authored by '
f'{author or "unknown"}, not listed human {by}'
)
elif 'docs/anchors.json' not in git('show', '--name-only', '--format=', sha):
problems.append(f'FAIL {page} {key}: commit {sha[:7]} never touched the manifest')
try:
age = (dt.date.today() - dt.date.fromisoformat(anchor.get('verified_at', ''))).days
except ValueError:
problems.append(f'FAIL {page} {key}: verified_at missing or not ISO')
continue
if age > MAX_AGE_DAYS:
problems.append(f'FAIL {page} {key}: anchor {age} days old, owner must re-verify')
return problems
def main() -> int:
manifest, humans = load('docs/anchors.json'), load('docs/people.json')
pages = drafted_pages()
problems = [p for page in pages for p in check_page(page, manifest, humans)]
if problems:
print('\n'.join(problems))
return 1
print(f'OK {len(pages)} drafted page(s) carry human anchors')
return 0
if __name__ == '__main__':
sys.exit(main())
Wire it into pre-commit first so authors see the message before a review starts. The CI job is the same command, and it should run on pull requests that touch docs/ rather than on every push.
# .pre-commit-config.yaml (excerpt)
repos:
- repo: local
hooks:
- id: anchor-gate
name: doc anchor gate
entry: python3 anchor_gate.py
language: system
files: ^docs/.*\.md$
Step 5 — Route the failures
The message determines the fix, and the three cases are worth separating in your team's own runbook.
| Failure | Who acts | Action |
|---|---|---|
missing from docs/anchors.json |
Page author | Write the five anchors before requesting review |
authored by ..., not listed human |
Reviewer | Replace the evidence with a human commit, do not edit the allow-list |
anchor N days old |
Named owner | Re-verify the claim against the current code, then bump verified_at
|
never touched the manifest |
Author | Anchor commit must include the manifest change in the same commit |
The second row is the one people are tempted to shortcut by adding the bot address to people.json. Doing so converts a real ownership check into a formality, and the gate will happily pass pages that no human has read.
Test plan you can run in ten minutes
Build three fixtures from a copy of your docs directory and confirm the exit codes before trusting the gate in CI.
mkdir -p test/fixtures && cp -r docs test/fixtures/missing
python3 - <<'PY'
import json, pathlib
p = pathlib.Path('test/fixtures/missing/docs/anchors.json')
p.write_text(json.dumps({}), encoding='utf-8')
PY
(cd test/fixtures/missing && python3 anchor_gate.py; echo "exit=$?")
Expected output: FAIL docs/queue-retries.md: drafted but missing from docs/anchors.json with exit=1. For the stale case, set one verified_at to 2025-01-01 and expect a message naming that anchor and its age in days. For the authorship case, point one commit at any commit made by the drafting identity and expect the not a listed human message; if that commit still passes, your allow-list is wrong.
What the model may draft, and what stays human
The split is easier to hold when it is written down once, because models are genuinely good at the left column and unreliable at the right one.
| Draftable by a model | Human-owned, with dated evidence |
|---|---|
| Overview prose and section summaries | Limits, preconditions, and supported configurations |
| Worked examples built from code in the repo | Security, compliance, and data-handling statements |
| Glossary restatement and term expansion | Any number a reader might budget against |
| Error-message transcription and formatting | Deprecation dates and migration order |
| Reordering an existing page without changing claims | Claims about what the team will not support |
The right column is not a comment on model quality; it is a comment on who can be asked to defend the sentence later. A model can draft a limits section that reads well, and a human still has to own whether the limit is true this quarter.
Running the loop on one machine
The drafting half needs a model endpoint, and the gate needs somewhere to run on a schedule if you want the staleness check to fire without a pull request. MonkeyCode offers free model access and a free server option, which is enough to draft marked regions and to run anchor_gate.py as a nightly job against the manifest; the availability claims are the operator's, so verify current terms yourself before you depend on them.
Disclosure: This article was prepared as part of MonkeyCode's product outreach.
Start with one page that has caused a support ticket, add the five anchors, and let the gate run in report-only mode for a week before it blocks anything. If that page becomes easier to defend after a model rewrite, the same loop will hold for the next twenty.
Limitations and who should not use this
The gate proves that a named human committed an anchor, not that the anchor is correct, so a confident wrong claim still passes. Re-verification is a manual 90-day chore, and a busy team will let dates slip unless the nightly job has an owner of its own.
It also adds real friction: expect roughly twenty minutes per page on the first pass, and more if the manifest has to be reconstructed after a docs migration. Pages generated from source, such as API references and schema docs, get no benefit because their claims are already traceable to a definition.
Skip this approach for incident timelines where speed dominates, for throwaway notes, and for single-maintainer projects where the owner is never in question. Ownership is a small artifact you can check, and the point of the gate is simply to stop that artifact from quietly disappearing in the next model-assisted rewrite.
Top comments (0)