Give the model the paragraphs and keep the claims to yourself; that split is enforceable in CI with three small files. Authorship labels like "model-drafted" or "human-reviewed" record who touched a page, and they say nothing about which sentences a reviewer is actually signing. The workflow below classifies every added line by claim type, fails the build when a human-only claim arrives without a content-addressed attestation, and leaves narrative drafting entirely to the model.
Why the unit of ownership is a claim, not a page
A page is too coarse a unit for accountability, because most docs mix harmless transitions with load-bearing numbers in the same paragraph. A reviewer who approves "the queue drains quickly" is making a different kind of statement than one who approves "the queue drains in 250 ms", yet both arrive in one diff. Claim classes give you a mechanical boundary that a script can check and a human can be named against. That boundary is also what makes model drafting safe to scale: the model can rewrite the whole page twice a day as long as it never invents an unattested fact.
This is deliberately narrower than an accounting log of who wrote what. If you already track authorship per lane, this gate answers a different question: which specific changed lines require a named human and a primary source before merge.
Step 1: Write the drafting-rights manifest
The manifest lists claim classes as regexes and maps path globs to the classes that are human-only there. Anything not matched by a glob stays draftable, which keeps the default cheap to reason about. Keep the class list small and start with four: numbers with units, version pins, security and compliance words, and API surface lines.
{
"version": 1,
"human_only": {
"docs/**/*.md": ["numeric-limit", "version-pin", "security-claim", "api-surface"],
"docs/reference/**/*.md": ["numeric-limit", "version-pin", "security-claim", "api-surface", "compatibility"]
},
"classes": {
"numeric-limit": {"regex": "\\b\\d+(?:\\.\\d+)?\\s?(?:ms|s|MB|GB|GiB|rps|req/s|%)\\b"},
"version-pin": {"regex": "\\bv?\\d+\\.\\d+(?:\\.\\d+)?\\b"},
"security-claim": {"regex": "(?i)\\b(encrypted|compliant|audited|guarantee[sd]?|SOC ?2|GDPR|ISO ?27001)\\b"},
"api-surface": {"regex": "^\\s*(?:def |class |curl |POST |GET |PUT |DELETE )"},
"compatibility": {"regex": "(?i)\\b(deprecated|breaking change|backwards?[- ]compatible|supported until)\\b"}
}
}
Store this as .draftrights.json at the repo root. JSON is chosen over YAML so the checker needs nothing outside the standard library, and so a bad manifest fails loudly instead of resolving to a surprising default.
Step 2: Add the checker
The checker reads the manifest, parses git diff -U0 for added lines, and tests each line against the classes allowed for its path. Attestations are keyed by a hash of the trimmed line, so editing an attested number silently invalidates its receipt. This is the single most useful property of the design: it converts "someone approved this number" into "someone approved this exact number".
#!/usr/bin/env python3
"""Fail when a model-authored diff adds a human-only claim without attestation."""
from __future__ import annotations
import argparse
import hashlib
import json
import re
import subprocess
import sys
from pathlib import Path
MANIFEST_PATH = Path(".draftrights.json")
ATTEST_PATH = Path("attestations/claims.json")
def glob_to_regex(pattern: str) -> re.Pattern[str]:
out, i = [], 0
while i < len(pattern):
if pattern.startswith("**/", i):
out.append("(?:.*/)?"); i += 3
elif pattern.startswith("**", i):
out.append(".*"); i += 2
elif pattern[i] == "*":
out.append("[^/]*"); i += 1
elif pattern[i] == "?":
out.append("[^/]"); i += 1
else:
out.append(re.escape(pattern[i])); i += 1
return re.compile("^" + "".join(out) + "$")
def classes_for(path: str, manifest: dict) -> list[str]:
hits: list[str] = []
for glob, names in manifest["human_only"].items():
if glob_to_regex(glob).match(path):
hits.extend(names)
return sorted(set(hits))
def added_lines(base: str) -> list[tuple[str, str]]:
diff = subprocess.run(
["git", "diff", "-U0", "--no-color", f"{base}...HEAD"],
capture_output=True, text=True, check=True,
).stdout
path, rows = None, []
for line in diff.splitlines():
if line.startswith("+++ b/"):
path = line[6:]
elif path and line.startswith("+") and not line.startswith("+++"):
rows.append((path, line[1:]))
return rows
def line_hash(text: str) -> str:
return hashlib.sha256(text.strip().encode("utf-8")).hexdigest()
def main() -> int:
ap = argparse.ArgumentParser()
ap.add_argument("--base", default="origin/main")
args = ap.parse_args()
manifest = json.loads(MANIFEST_PATH.read_text())
raw = json.loads(ATTEST_PATH.read_text())["claims"] if ATTEST_PATH.exists() else []
attested = {(c["path"], c["class"], c["line_sha256"]) for c in raw}
patterns = {n: re.compile(s["regex"]) for n, s in manifest["classes"].items()}
violations, checked = [], 0
for path, text in added_lines(args.base):
names = classes_for(path, manifest)
if not names or not text.strip():
continue
for name in names:
if not patterns[name].search(text):
continue
checked += 1
if (path, name, line_hash(text)) not in attested:
violations.append((path, name, text.strip()))
if violations:
print(f"{len(violations)} of {checked} human-only claims are unattested:\n")
for path, name, text in violations:
print(f" [{name}] {path}: {text}")
print("\nAttest each line in attestations/claims.json with a primary source,")
print("or rewrite the sentence so it makes no factual claim.")
return 1
print(f"ok: {checked} human-only claim(s) checked, all attested")
return 0
if __name__ == "__main__":
sys.exit(main())
Run it locally with python tools/draftrights.py --base origin/main and wire the same command into CI. The exit code is the contract, so the CI step needs no parsing of human-readable output.
Step 3: Make attestations content-addressed
The attestation file holds one record per accepted claim, with the attester, the date, and the primary source that backs the sentence. The hash is over the trimmed line, which means a whitespace-only reformat keeps the receipt valid while a changed digit does not.
{
"claims": [
{
"id": "rate-limit-429",
"path": "docs/reference/limits.md",
"class": "numeric-limit",
"line_sha256": "6d1a0f0d6f5b6c1b1a4c8f5b3a0d9e2f3c7b8a1d4e5f60718293a4b5c6d7e8f9",
"attested_by": "docs-owner",
"attested_on": "2026-10-20",
"source": "https://example.internal/reference/limits"
}
]
}
Generate hashes with a one-liner so nobody hand-computes them, and copy the exact sentence from the doc into the command:
python -c 'import hashlib,sys;print(hashlib.sha256(sys.argv[1].strip().encode()).hexdigest())' \
'The endpoint returns 429 after 100 requests per second.'
The source field is the part that matters for review: it forces the attester to point at a primary document rather than at a previous version of the same page. If the source cannot be produced, the honest resolution is to rewrite the sentence without the number.
Step 4: Prove the gate with four fixtures
A gate that never fails is indistinguishable from a gate that does not run, so build the fixtures before you trust the default branch. The plan below is a specification to execute in a scratch branch; treat it as unexecuted until you see the exit codes in your own repo.
-
Narrative only. Add a paragraph with no numerals, versions, or API lines. Expect
ok: 0 human-only claim(s) checkedand exit 0. -
Bare number. Append
The endpoint returns 429 after 100 requests per second.and commit. Expect onenumeric-limitviolation and exit 1. -
Attested number. Add the matching record from Step 3, re-run, and expect exit 0 with
all attested. -
Mutation. Edit
100to200in the doc without touching the attestation. Expect exit 1 again, because the line hash no longer resolves.
git switch -c fixture/numeric
printf '\nThe endpoint returns 429 after 100 requests per second.\n' >> docs/reference/limits.md
git commit -am 'docs: model draft'
python tools/draftrights.py --base origin/main; echo "exit=$?"
Fixture 4 is the one to keep in CI as a meta-test, since it guards the property that makes the whole workflow worth maintaining.
Where the free model and the free server fit
MonkeyCode's free model access is the drafting environment in this loop: the model rewrites narrative sections and proposes edits, then the checker decides whether the result may merge. The free server option is where the checker and the fixture fixtures can run, which keeps the gate off a metered CI runner while you tune regexes. Disclosure: This article was prepared as part of MonkeyCode's product outreach. The gate itself is provider-agnostic, and the script above will behave identically against any model endpoint.
One useful prompt constraint: ask the model to leave a <!-- claim: numeric-limit --> marker on any sentence it cannot ground in the repo or a linked source. The regexes catch what the model forgets; the markers catch what the regexes miss. Reading both signals is a small extension to the checker and a reasonable second iteration.
Decision table
| Claim class | Model may draft | Human must own | Gate |
|---|---|---|---|
| Narrative, transitions, examples | Yes, freely | Nothing | None |
| Numbers with units | Proposal only | The number and its source |
line_sha256 attestation |
| Version pins | Proposal only | The pin, verified against release notes | Attestation plus source URL |
| Security / compliance wording | No | The exact sentence | Attestation from a named owner |
| API surface and signatures | No | Copy from code or OpenAPI | Attestation, reviewed with the code change |
| Deprecation and compatibility | Proposal only | The support window | Attestation with an expiry date |
Limitations and who should not adopt this
Regex classes are a blunt instrument: they miss hedged prose ("considerably faster"), non-English pages, and numbers inside tables or screenshots. Content-addressed receipts also make formatting churn visible, so bulk reflows of an attested paragraph will require re-attestation, and the honest answer there is to reflow less. Finally, a commit trailer or attestation file is a social control rather than a security control, because anyone with write access can forge one; it works because it names a person and a source in the same diff.
Skip this if you generate reference docs from code and never hand-write numbers, since a doc generator already gives you a stronger guarantee. Skip it too if no one on the team owns a page, because attestation with no owner collapses into a rubber stamp. It fits teams where a model drafts often, a reviewer merges daily, and at least one fact per page would be expensive to get wrong.
If you want to try the loop before wiring your own runner, a scratch repository plus the free model access and free server option is enough to run all four fixtures end to end.
Top comments (0)