The boundary between what a model may draft and what a human must own survives only if it is machine-readable and checked on every commit. This article puts that boundary inside the Markdown file itself, then adds a small Python gate that fails a pull request when drafting crosses it. The gate says nothing about writing quality and nothing about whether a claim is true; it enforces ownership, which is the constraint review processes lose first when drafts arrive faster than reviewers. Everything below is ordinary file parsing, so the approach works with any drafting model and any editor.
The failure this prevents
Most documentation pipelines treat a page as one unit of review, so a model asked to tighten a summary can also rewrite the on-call rotation and the deprecation warning. Reviewers then read the diff as a whole, approve the paragraphs they recognize, and skim the ones they do not. The result is a sentence that reads as finished, sits in a section no named person owns, and gets quoted by a support engineer three weeks later. Region markers change the unit of review from the page to the paragraph, and hashing the human-owned paragraphs turns ownership into a check that runs without a reviewer in the room.
Two region kinds, one rule each
| Region kind | Who may edit it | What the gate verifies |
|---|---|---|
scope:draft |
the drafting model, any time | the set of draft region ids is unchanged |
scope:human |
a named person, through review | the normalized text hash matches the manifest |
The rule for a draft region is deliberately loose, because that is where drafting adds value. The rule for a human region is deliberately strict, because a silent edit there is the failure this workflow exists to catch. The gate compares identifiers and hashes and never parses prose, so it stays cheap enough to run on every commit.
Step 1: mark the regions in the page
Markers are HTML comments, which disappear from rendered output and stay visible in raw diffs. Use one region per paragraph cluster, put the owner in the id when ownership is not obvious, and never nest regions.
# Rate limits
<!-- scope:draft id=summary -->
The API returns 429 responses when a client exceeds its window.
<!-- /scope:draft -->
<!-- scope:human id=limits-owner-sre -->
The current window is 60 seconds and is configured per key.
Contact #platform-sre before changing it.
<!-- /scope:human -->
Three details matter. Ids are stable identifiers rather than titles, so renaming a heading does not invalidate the manifest. A human region should contain only statements that need accountability, not every sentence on the page. Marker lines are excluded from the hash, which lets a person rename an id without pretending the content changed.
Step 2: hash only the text a human owns
Hashing raw bytes fails on trailing whitespace and editor reflow, so normalize before digesting. Strip trailing spaces on each line, drop leading and trailing blank lines, and join with a single newline. Do not collapse interior spaces and do not strip inside fenced code blocks, because that is where a changed value hides.
# tools/scopecheck.py (part 1: the hashing core)
import hashlib
def normalize(lines):
kept = [line.rstrip() for line in lines]
while kept and not kept[0].strip():
kept.pop(0)
while kept and not kept[-1].strip():
kept.pop()
return '\n'.join(kept)
def digest(lines):
body = normalize(lines).encode('utf-8')
return 'sha256:' + hashlib.sha256(body).hexdigest()[:16]
A truncated 16-character digest is enough here because the comparison set is small and local. It is not a security control, and it should not be treated as proof that a paragraph is trustworthy. Its only job is to detect that bytes under a protected marker moved since the manifest was written.
Step 3: parse, compare, and exit non-zero
The checker walks the docs tree, parses markers line by line, and refuses malformed markup before comparing anything. A nested region, an unclosed region, or a duplicated id is a hard error with exit code 2, because a gate that guesses is worse than a gate that stops.
# tools/scopecheck.py (part 2: the checker, same file as part 1)
import argparse
import json
import re
import sys
from pathlib import Path
MARKER = re.compile(
r'^<!--\s*(?P<close>/)?scope:(?P<kind>draft|human)\s+id=(?P<rid>[A-Za-z0-9_.-]+)\s*-->\s*$'
)
class BadMarkup(Exception):
pass
def parse(path):
regions, stack = {}, []
for lineno, line in enumerate(path.read_text(encoding='utf-8').splitlines(), 1):
found = MARKER.match(line)
if found is None:
if stack:
stack[-1]['lines'].append(line)
continue
rid, kind = found.group('rid'), found.group('kind')
if found.group('close') is None:
if stack:
raise BadMarkup(f'{path}:{lineno}: nested region in {stack[-1]["rid"]}')
stack.append({'rid': rid, 'kind': kind, 'line': lineno, 'lines': []})
continue
if not stack:
raise BadMarkup(f'{path}:{lineno}: close marker without an open marker')
opened = stack.pop()
if (opened['rid'], opened['kind']) != (rid, kind):
raise BadMarkup(f'{path}:{lineno}: close marker does not match {opened["rid"]}')
if rid in regions:
raise BadMarkup(f'{path}:{lineno}: duplicate region id {rid}')
regions[rid] = {'kind': kind, 'line': opened['line'], 'digest': digest(opened['lines'])}
if stack:
raise BadMarkup(f'{path}: unclosed region {stack[-1]["rid"]}')
return regions
def scan(root):
state = {}
for path in sorted(root.rglob('*.md')):
regions = parse(path)
state[str(path.relative_to(root))] = {
'draft': sorted(r for r, v in regions.items() if v['kind'] == 'draft'),
'human': {r: v['digest'] for r, v in regions.items() if v['kind'] == 'human'},
}
return state
def compare(baseline, current):
problems = []
for name, expected in baseline.items():
actual = current.get(name)
if actual is None:
problems.append(f'{name}: file is gone but the manifest still lists it')
continue
if actual['draft'] != expected['draft']:
problems.append(f'{name}: draft region ids changed')
for rid, want in expected['human'].items():
got = actual['human'].get(rid)
if got is None:
problems.append(f'{name}: human region {rid} is missing')
elif got != want:
problems.append(f'{name}: human region {rid} was edited ({want} -> {got})')
for rid in actual['human']:
if rid not in expected['human']:
problems.append(f'{name}: human region {rid} is not in the manifest')
for name in current:
if name not in baseline:
problems.append(f'{name}: has regions but no manifest entry, run --update')
return problems
def main(argv=None):
ap = argparse.ArgumentParser()
ap.add_argument('root', type=Path)
ap.add_argument('--manifest', type=Path, default=Path('.docscope/manifest.json'))
ap.add_argument('--update', action='store_true')
ap.add_argument('--reviewed-by', default='')
args = ap.parse_args(argv)
try:
current = scan(args.root)
except BadMarkup as exc:
print(f'scopecheck: {exc}', file=sys.stderr)
return 2
if args.update:
if not args.reviewed_by:
print('scopecheck: --update requires --reviewed-by', file=sys.stderr)
return 2
args.manifest.parent.mkdir(parents=True, exist_ok=True)
payload = json.dumps(current, indent=2, sort_keys=True) + '\n'
args.manifest.write_text(payload, encoding='utf-8')
print(f'scopecheck: manifest written, reviewed by {args.reviewed_by}')
return 0
if args.manifest.exists():
baseline = json.loads(args.manifest.read_text(encoding='utf-8'))
else:
baseline = {}
problems = compare(baseline, current)
for problem in problems:
print(f'scopecheck: {problem}', file=sys.stderr)
return 1 if problems else 0
if __name__ == '__main__':
raise SystemExit(main())
Two design choices in that script are worth defending. Draft regions are tracked by id rather than by hash, so routine rewriting creates no manifest churn and reviewers only see human-owned changes. And --update refuses to run without --reviewed-by, so refreshing the baseline always records a person, which turns the manifest into a lightweight approval log instead of a cache.
Step 4: run it before the diff is reviewed
Run the gate locally through a pre-commit hook and again in CI, so a missing hook cannot become the reason a bad edit merges.
python tools/scopecheck.py docs --manifest .docscope/manifest.json
# .git/hooks/pre-commit
#!/bin/sh
python tools/scopecheck.py docs --manifest .docscope/manifest.json || {
echo 'Human-owned doc region changed. Review it, then re-run with:'
echo ' python tools/scopecheck.py docs --update --reviewed-by "$(git config user.name)"'
exit 1
}
- name: human-owned doc regions unchanged
run: python tools/scopecheck.py docs --manifest .docscope/manifest.json
When a person legitimately edits a protected paragraph, the flow is to edit, run the update command with their own name, and commit the page and the manifest together. The manifest diff then shows exactly which protected regions moved, which is a far shorter thing to review than a regenerated page.
Step 5: test the gate before trusting it
A gate that has never failed has never been tested. These four tests cover the cases that matter, and they finish in well under a second.
# tests/test_scopecheck.py
import pytest
import scopecheck
PAGE = '''# Rate limits
<!-- scope:draft id=summary -->
The API returns 429 responses when a client exceeds its window.
<!-- /scope:draft -->
<!-- scope:human id=limits-owner-sre -->
The current window is 60 seconds and is configured per key.
<!-- /scope:human -->
'''
def build(tmp_path, name, text):
doc = tmp_path / name / 'docs' / 'rate-limits.md'
doc.parent.mkdir(parents=True)
doc.write_text(text, encoding='utf-8')
return doc.parent
def test_draft_rewrite_is_allowed(tmp_path):
before = scopecheck.scan(build(tmp_path, 'a', PAGE))
after = scopecheck.scan(build(tmp_path, 'b', PAGE.replace('429 responses', 'HTTP 429')))
assert scopecheck.compare(before, after) == []
def test_human_edit_is_flagged(tmp_path):
before = scopecheck.scan(build(tmp_path, 'a', PAGE))
after = scopecheck.scan(build(tmp_path, 'b', PAGE.replace('60 seconds', '30 seconds')))
assert 'edited' in scopecheck.compare(before, after)[0]
def test_removed_human_region_is_flagged(tmp_path):
before = scopecheck.scan(build(tmp_path, 'a', PAGE))
stripped = PAGE.replace('<!-- scope:human id=limits-owner-sre -->\n', '')
stripped = stripped.replace('<!-- /scope:human -->', '')
after = scopecheck.scan(build(tmp_path, 'b', stripped))
assert scopecheck.compare(before, after)
def test_nested_markers_are_rejected(tmp_path):
bad = '<!-- scope:draft id=a -->\n<!-- scope:draft id=b -->\n<!-- /scope:draft -->\n'
with pytest.raises(scopecheck.BadMarkup):
scopecheck.scan(build(tmp_path, 'a', bad))
Run it with pytest -q tests/test_scopecheck.py. If the human-edit test passes while the manifest is untouched, the gate is decorative and you should not rely on it yet.
Where the drafting half fits
Region markers only matter if something drafts inside them, and a drafting job needs somewhere to run. MonkeyCode's free model access and free server option, both as described by the operator, cover those two pieces: the model access drafts only scope:draft regions against a fixed prompt, and the server option runs the batch job without a workstation left awake. The prompt stays boring on purpose, because the gate, not the instruction, defines the boundary.
You may rewrite text between scope:draft markers only.
Return the full file. Do not add, remove, or rename any marker.
Do not touch text between scope:human markers.
Disclosure: This article was prepared as part of MonkeyCode's product outreach. If you want to try the drafting half without a billing setup, start with the free model access and let the gate tell you quickly whether drafts stay inside their regions.
What this gate does not prove
- It proves a human region is byte-identical, not correct; a stale paragraph nobody touched passes happily.
- It cannot stop semantic drift inside draft regions, so claim-level review still applies to everything the model rewrites.
- Marker removal is caught only through the draft id set, so a rewrite that consistently reshapes the whole file is out of scope.
- It says nothing about files outside the scanned root, about content in a CMS, or about translations, which need their own manifest per locale.
Who should not adopt this
Teams with a single documentation author gain little, because the hash gate duplicates a review step that already happens every time. Repositories where docs live in a product CMS should fix the publishing path before adding markers. And teams whose protected sections change weekly will turn --update into a reflex, at which point the manifest becomes a rubber stamp and stops meaning anything.
Adoption checklist
- Mark two pages rather than twenty, and pick sections that genuinely need a named owner.
- Generate the manifest with
--reviewed-byset to a real reviewer name. - Add the CI step and confirm it fails on a deliberate edit inside a human region.
- Write the four tests, run them, and only then enable the drafting job.
Top comments (0)