Model-drafted documentation usually fails at the level of one word, not one page. A fluent rewrite can delete a negation, widen a numeric limit, or weaken a modal verb while every surrounding paragraph genuinely improves. Section-level ownership cannot see that kind of edit, because the reviewer checks a heading and the guarantee lives three lines below it.
MonkeyCode's free model access and free server option make a repeated drafting pass cheap enough to run often, which is exactly why the guard rails have to be cheaper still. Disclosure: This article was prepared as part of MonkeyCode's product outreach.
The failure mode is a flipped guard word
Generated prose has a recognizable signature: well organized, readable, and wrong by one token. Real examples from review queues look like never becoming generally, at most three times becoming up to three times, or only admin tokens losing the word only. Because the paragraph around the edit reads better than before, reviewers approve the improvement and inherit the constraint.
A section owner answers a social question, namely who looked at this heading. The checker below answers a mechanical question instead, namely whether the text that carries a guarantee is byte-identical after normalization. Those are different properties, and only the second one survives a busy review week.
A delegation table you can argue about
Before writing code, decide which sentence classes a model may draft. The table below is the one I would defend in review; change the rows deliberately rather than by accident.
| Sentence class | Model may draft | Human must own | Typical silent failure |
|---|---|---|---|
| Narrative explanation, motivation, background | Yes | Final read only | Tone drift, invented rationale |
| Numbers copied from config or code | Only via generation from source | The mapping rule | Rounded or stale limits |
| Negations, exceptions, exclusions | No | Author who knows why | A dropped not inverts the contract |
| Modal verbs such as must, may, should | No | API or security owner |
must softened to may
|
| Preconditions and call ordering | No | Owner of the runtime path | Ordering stated but not enforced |
| Compatibility and deprecation promises | No | Release owner | Promise survives a version bump |
Notice that the human-owned column is small. Most of a page is delegable, and the non-delegable part is usually a handful of sentences that deserve their own review path.
Step 1: mark non-delegable spans in the source
Use an HTML comment pair, require a stable id, and keep one span per guarantee. The id is what the lock file keys on, so renaming it is a deliberate act reviewed like a code change.
<!-- owned:start id=retry-cap -->
The client retries **at most three times** on 5xx responses and never retries a 4xx response.
<!-- owned:end -->
Give the drafting model one instruction and repeat it verbatim: rewrite anything outside owned:start and owned:end, and never emit those markers yourself. If the model deletes a marker instead of the text, the checker below reports marker drift rather than a rewritten guarantee, which is a different failure with a different owner.
Step 2: normalize narrowly, then hash
Normalization decides how much cosmetic change you tolerate. Strip markdown emphasis, collapse whitespace, and stop there. Casefolding is usually safe, but stemming, synonym mapping, or sentence splitting all hide the edits you built the system to catch.
def normalize(s):
s = re.sub(r'[*_`]', '', s)
return ' '.join(s.split())
def digest(s):
return hashlib.sha256(normalize(s).encode()).hexdigest()[:16]
The result is intentionally boring: reflowing a paragraph or removing bold text passes, while changing three to five fails. That split matches how documentation actually gets edited, where formatting churn is frequent and guarantee edits are rare but expensive.
Step 3: the checker, with three exit codes
The script below is standard-library Python and runs in well under a second on a few hundred pages. Exit code 0 means all spans match, 1 means a locked span was rewritten, and 2 means the marker set moved and needs a human decision.
#!/usr/bin/env python3
# tools/owned.py - lock and verify non-delegable doc sentences
import hashlib, json, re, sys
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
DOCS = ROOT / 'docs'
LOCK = DOCS / 'owned.lock.json'
START = re.compile(r'<!--[ ]*owned:start[ ]+id=(?P<id>[a-z0-9-]+)[ ]*-->')
END = re.compile(r'<!--[ ]*owned:end[ ]*-->')
NEG = {'not', 'no', 'never', 'cannot', 'without', 'nor'}
MODAL = {'must', 'may', 'should', 'shall', 'can', 'will'}
SCOPE = {'only', 'except', 'all', 'any', 'least', 'most', 'always'}
def spans(text):
found, cur, buf = {}, None, []
for line in text.splitlines():
hit = START.search(line)
if hit:
cur, buf = hit.group('id'), []
continue
if END.search(line) and cur:
found[cur] = ' '.join(buf)
cur = None
continue
if cur is not None:
buf.append(line)
return found
def normalize(s):
s = re.sub(r'[*_`]', '', s)
return ' '.join(s.split())
def digest(s):
return hashlib.sha256(normalize(s).encode()).hexdigest()[:16]
def classes(s):
toks = re.findall(r'[a-z0-9]+', normalize(s).lower())
return {
'neg': sorted({t for t in toks if t in NEG}),
'modal': sorted({t for t in toks if t in MODAL}),
'num': sorted({t for t in toks if t.isdigit()}),
'scope': sorted({t for t in toks if t in SCOPE}),
}
def scan():
out = {}
for path in sorted(DOCS.rglob('*.md')):
for sid, text in spans(path.read_text()).items():
out[sid] = {
'file': str(path.relative_to(ROOT)),
'sha': digest(text),
'cls': classes(text),
}
return out
def main(argv):
mode = argv[1] if len(argv) > 1 else 'check'
force = '--force' in argv
lock = json.loads(LOCK.read_text()) if LOCK.exists() else {}
current = scan()
if mode == 'seal':
blocked = [k for k, v in lock.items()
if k in current and current[k]['sha'] != v['sha']]
if blocked and not force:
print('reseal needs owner review, then --force:', ', '.join(blocked))
return 2
LOCK.write_text(json.dumps(current, indent=2, sort_keys=True) + '\n')
print('sealed', len(current), 'spans')
return 0
drift = sorted(set(current) ^ set(lock))
if drift:
print('marker drift:', ', '.join(drift))
return 2
failed = 0
for sid, entry in current.items():
if entry['sha'] == lock[sid]['sha'] and entry['file'] == lock[sid]['file']:
continue
failed += 1
print('FAIL', sid, entry['file'])
for k in ('neg', 'modal', 'num', 'scope'):
lost = set(lock[sid]['cls'][k]) - set(entry['cls'][k])
gained = set(entry['cls'][k]) - set(lock[sid]['cls'][k])
if lost or gained:
print(' ', k, 'lost', sorted(lost), 'gained', sorted(gained))
print('checked', len(current), 'spans,', failed, 'rewritten')
return 1 if failed else 0
if __name__ == '__main__':
sys.exit(main(sys.argv))
Storing the four token classes in the lock file is what makes a failure readable without keeping a copy of the prose. The lock file stays small, and a failing run tells the reviewer which class moved instead of only that a hash differs.
Step 4: read the failure output first, then the diff
When a rewrite is caught, the output names the mechanism rather than the location. A run against a seeded change to the retry sentence above should print something close to the block below.
FAIL retry-cap docs/concepts/retry.md
neg lost ['never'] gained []
num lost ['3'] gained ['5']
checked 14 spans, 1 rewritten
That output is the review ticket. The reviewer opens one sentence, confirms whether the change was intended, and either reverts it or re-seals the lock in a commit that a human authored. Nothing in this loop requires reading the whole page again, which is the actual time saving.
Step 5: sealing is a human commit
A lock that the drafting process can update is decoration. Make seal refuse to overwrite a changed span without an explicit --force, and add a workflow step that rejects any pull request touching the lock file before a human has reviewed the change.
# CI step for docs pull requests
if git diff --name-only "origin/$GITHUB_BASE_REF..." | grep -qx 'docs/owned.lock.json'; then
echo 'lockfile edited in a draft PR: needs a separate human seal commit'
exit 1
fi
python tools/owned.py check
Two commits are better than one here: the model-authored rewrite, then the human seal. That ordering leaves an audit trail naming the person who accepted the new guarantee, which is the only part of this system that cannot be automated away.
Step 6: a mutation test plan for the checker
The table below is a plan, not a measured result, and it takes about ten minutes to execute against the script above. Seed each edit by hand, run the checker, and record the exit code; if any row disagrees with your implementation, the normalizer is too permissive.
| Seeded edit | Expected detection | Exit code |
|---|---|---|
at most three becomes up to five
|
hash mismatch, num lost 3, gained 5 | 1 |
never retries becomes rarely retries
|
neg lost never
|
1 |
must call init becomes may call init
|
modal lost must, gained may
|
1 |
only admin tokens becomes admin tokens
|
scope lost only
|
1 |
| bold removed, line reflowed | normalized text equal | 0 |
| marker pair moved to another file | file field differs, reported as FAIL | 1 |
| marker pair deleted | marker drift | 2 |
Keep this table in the repository next to the script. When someone later proposes a looser normalizer, the seed rows are the argument, and a passing mutation run is much harder to dismiss than a policy document.
Where the free model and free server actually fit
Use the free drafting pass for the delegable majority and keep the locked spans out of its write scope. On the free server option, clone the repository to a scratch directory, run the drafting pass there, and let the model execute python tools/owned.py check on its own output before any human looks at it.
PASS 1: rewrite docs/concepts/retry.md. Do not emit or edit text between
owned:start and owned:end markers. Do not edit docs/owned.lock.json.
PASS 2: list every owned span you wanted to change, with a one-line reason.
Do not apply those changes.
The second pass is the useful one, because it turns silent rewrites into a review queue. In practice the model often proposes a legitimate update such as a new default timeout, and the request arrives as text a human can accept or reject instead of as a diff mixed into prose.
I have not measured throughput, cost, or model quality here, and free-tier terms change. Treat the availability of free model access and the free server as operator-supplied information and re-verify it against current terms before you build a workflow around it.
Limitations and who should skip this
Small documentation sets with one maintainer gain little, because the review problem the lock solves does not exist yet. Teams whose guarantees change every sprint will generate seal churn and should start with two or three spans, not twenty. Repositories without CI cannot enforce the seal step and should not pretend the lock file is a control.
Marketing copy and tutorials with no contractual claims fit poorly, since almost nothing in them is worth hashing. Multilingual documentation needs the check to run per locale, or the translation becomes an unguarded edit path. Finally, this workflow protects sentences you remembered to mark, and no script can find the guarantee you never wrote down.
If you try this on a real documentation repository, the first useful artifact is not the passing check but the first FAIL line, because it names a guarantee that was already being edited without review.
Top comments (0)