The cheapest control you can put on a documentation generator is not a stricter prompt. It is a file format that gives the model no legal place to write except the prose blocks you hand it. This post defines three block classes in Markdown, a linter that fails on structural drift, and a splice step that can only rewrite the blocks it was granted. When a model tries to "improve" the deprecation warning a human wrote after an outage, the pipeline goes red instead of the warning quietly disappearing into a green diff.

1. The failure this design removes

Ask a general model to improve clarity across a whole page and it will return a whole page, including paragraphs a person added under pressure at 2 a.m. Reviewers catch that substitution most of the time; the misses cluster exactly where the review queue is longest. The root cause is not model quality. It is that write permissions live in the prompt text rather than in the artifact being edited.

A file that encodes ownership changes what review costs. Instead of reading every line to infer whether it was allowed to change, a reviewer reads a diff grouped by class and spends attention on the class that needs judgment. Structure performs the filtering that attention keeps failing to perform on a Friday afternoon.

2. Three block classes, three levels of write rights

Partition each page before you generate anything, and partition by claim type rather than by heading length.

Class Typical content Who may write it Merge rule
facts route tables, flag defaults, version strings, config keys a script reading the repo or build output regenerated in CI, never hand-edited
prose motivation, tutorial steps, examples, comparisons model draft, then human review one approving review
human deprecations, "not for production", security caveats, migration order, cost warnings a named person any model-authored change fails the build

The rule of thumb that keeps the split honest: if a sentence could change what someone deploys tonight, it belongs in a human block. Explanations can be redrafted cheaply and repeatedly. Verdicts cannot, because a plausible rewrite of a verdict looks identical to a correct one until production disagrees.

3. Step 1 — fix a fence syntax boring enough to grep

Open and close each block on its own line, forbid nesting, and give every block a stable id. The closing fence repeats only the class.

<!-- owner:human id=deprecation-charge-v1 ticket=OPS-441 --> > **Deprecated.** `POST /v1/charge` stops accepting new API keys on 2026-12-01. > New integrations must call `POST /v2/charges`; migration order is below. <!-- owner:human --> <!-- owner:prose id=intro --> This page covers both charge endpoints and the sequencing required to migrate a live integration. <!-- owner:prose --> <!-- owner:facts id=route-table src=scripts/gen_routes.py --> | Method | Path | State | | --- | --- | --- | | POST | /v2/charges | current | | POST | /v1/charge | deprecated | <!-- owner:facts --> 

Three invariants do most of the work here: one fence per line, unique ids per file, and facts blocks that name the script which produced them. Everything else is enforcement.

4. Step 2 — a linter that fails on structural drift

The linter below checks shape only, which is exactly why it can run on every pull request without needing credentials.

# tools/docfence.py — reference implementation, adapt before adopting. import re import sys from pathlib import Path FENCE = re.compile(r'^<!--[ ]*owner:(human|prose|facts)(.*?)-->$') ATTR = re.compile(r'([a-z]+)=([A-Za-z0-9._/-]+)') def scan(path): blocks, open_block, seen = [], None, set() for lineno, raw in enumerate(path.read_text(encoding='utf-8').splitlines(), 1): m = FENCE.match(raw.strip()) if not m: continue kind, rest = m.group(1), m.group(2).strip() if open_block is None: attrs = dict(ATTR.findall(rest)) if 'id' not in attrs: raise SystemExit('%s:%d: open fence without id' % (path, lineno)) if attrs['id'] in seen: raise SystemExit('%s:%d: duplicate id %s' % (path, lineno, attrs['id'])) if kind == 'facts' and 'src' not in attrs: raise SystemExit('%s:%d: facts block %s needs src=' % (path, lineno, attrs['id'])) seen.add(attrs['id']) open_block = {'kind': kind, 'id': attrs['id'], 'start': lineno} else: if kind != open_block['kind']: raise SystemExit('%s:%d: close %s inside %s' % (path, lineno, kind, open_block['kind'])) open_block['end'] = lineno blocks.append(open_block) open_block = None if open_block is not None: raise SystemExit('%s: unterminated %s block %s' % (path, open_block['kind'], open_block['id'])) return blocks if __name__ == '__main__': root = Path(sys.argv[1] if len(sys.argv) > 1 else 'docs') total = 0 for p in sorted(root.rglob('*.md')): found = scan(p) total += len(found) print('%s: %d blocks' % (p, len(found))) print('total: %d blocks' % total) 

Run it as python tools/docfence.py docs/ in the same job that runs your Markdown link checker. A page with an unterminated human block should never reach a reviewer, because the failure mode there is a verdict that silently loses its boundary.

5. Step 3 — pin human blocks to the last commit

A linter validates shape, not authorship, so pair it with a git comparison between HEAD and the working tree. The check below reports any human block whose body changed since the last commit, regardless of who changed it.

import subprocess def bodies(text): out, current = {}, None for line in text.splitlines(): m = FENCE.match(line.strip()) if m: rest = m.group(2) if 'id=' in rest: current = dict(ATTR.findall(rest))['id'] out[current] = [m.group(1), []] else: current = None continue if current is not None: out[current][1].append(line) return {k: (v[0], '\n'.join(v[1])) for k, v in out.items()} def check(path): head = subprocess.run(['git', 'show', 'HEAD:%s' % path], capture_output=True, text=True) if head.returncode != 0: return [] # new page, nothing to compare  before = bodies(head.stdout) after = bodies(Path(path).read_text(encoding='utf-8')) return [bid for bid, (kind, body) in before.items() if kind == 'human' and after.get(bid, (None, None))[1] != body] 

The escape hatch matters as much as the check itself. A deliberate rewrite should be a separate pull request that first removes the fence, so the change appears in review as an explicit unfencing rather than as an edit to protected text.

6. Step 4 — splice only what you were granted

Generation becomes a narrow operation with three rules: send only prose blocks plus read-only facts context, demand delimited output, and reject anything outside the grant.

OUT = re.compile(r'<<<block id=([A-Za-z0-9._-]+)>>>(.*?)<<<end>>>', re.S) def splice(page, model_out, granted): got = {m.group(1): m.group(2).strip() for m in OUT.finditer(model_out)} rogue = set(got) - set(granted) if rogue: raise SystemExit('model wrote outside its grant: %s' % sorted(rogue)) if '<!--' in model_out: raise SystemExit('model output contains a fence marker') for bid in got: page = replace_prose(page, bid, got[bid]) return page def replace_prose(page, bid, body): out, state = [], 'seek' for line in page.splitlines(): m = FENCE.match(line.strip()) attrs = dict(ATTR.findall(m.group(2))) if m else {} if state == 'seek' and attrs.get('id') == bid: if m.group(1) != 'prose': raise SystemExit('refusing to rewrite %s block %s' % (m.group(1), bid)) out.append(line) out.extend(body.splitlines()) state = 'skip' continue if state == 'skip': if m and 'id=' not in m.group(2): state = 'seek' out.append(line) continue out.append(line) return '\n'.join(out) + '\n' 

The splice call is a thin wrapper, which is where MonkeyCode's free model access fits: the drafting request only ever contains prose blocks and read-only context, so a free-tier model has no path to the verdict class even if the prompt is ignored. The free server option is a workable place to host the splice endpoint, since the whole service is a few hundred milliseconds of string work that gains nothing from being pinned to a laptop.

Disclosure: This article was prepared as part of MonkeyCode's product outreach.

Two notes on that setup. Free tiers and their terms change, so confirm the current offering before you build a CI job around it, and never let the endpoint hold write credentials to anything beyond the docs directory. The model is the least trustworthy component in this pipeline by design, and the fences exist so that assumption does not need to be re-argued every sprint.

7. Step 5 — fixtures that assume hostile output

Write the adversarial cases before the happy path, because a generator that behaves well on the first try is the least interesting one to have tested.

Model output Expected pipeline result
blocks for granted ids only splice applied, linter passes
whole page echoed back, human block included exit 1, "wrote outside its grant"
granted id whose body contains a fence exit 1, fence marker rejected
output wrapped in a Markdown code fence exit 1, no blocks parsed
one granted id missing previous body kept, warning logged
id renamed with a suffix exit 1, unknown id
import pytest from tools.docfence import splice HUMAN = """<!-- owner:human id=dep --> Do not use v1 after 2026-12-01. <!-- owner:human --> """ PROSE = """<!-- owner:prose id=intro --> Old text. <!-- owner:prose --> """ def test_whole_page_echo_is_rejected(): page = HUMAN + PROSE with pytest.raises(SystemExit): splice(page, page, granted={'intro'}) 

Keep those fixtures in tests/fixtures/ and rerun them whenever you change the prompt, the model, or the delimiter. Prompt changes are code changes, and they deserve the same regression coverage as anything else in the repository.

8. What the contract buys, and what it does not

The contract converts an attention problem into a structural check, which is the only kind of check that still works when the reviewer is tired. Human blocks appear in a diff as untouched by construction, so review time moves to prose, where judgment actually scales. That is the entire benefit, and it is worth having because prose is where most documentation volume lives anyway.

It does not verify that drafted prose is true, and it does not stop a reviewer from approving text they did not read. A model can still write a confident, wrong explanation inside its grant, and one person still needs to read every prose diff before merge. Treat the fences as a way to protect verdicts, not as a substitute for review.

9. Limitations and who should skip this

  • The workflow is Markdown-specific; documentation stored in a CMS or database needs equivalent permissions at the storage layer instead of in the file.
  • Fence comments are visible in raw source and occasionally render as stray HTML comments, which is harmless but looks untidy to some teams.
  • The git pin assumes one file per page and rebase discipline; long-lived branches will produce false drift that has to be resolved by hand.
  • Legitimate restructures are blocked by design, so you need a documented unfencing procedure or the contract becomes an obstacle people route around.
  • Solo maintainers with fully hand-written docs and teams generating pages entirely from a schema will get little from this; the split only pays off when drafted prose and human verdicts share a page.

10. A first commit you can finish this afternoon

mkdir -p tools tests/fixtures docs python tools/docfence.py docs/ # expect: total: 0 blocks # fence one page that already contains a deprecation or "not for production" note python tools/docfence.py docs/ git add tools docs && git diff --cached --stat 

Start with the page that carries your most expensive warning, since that is the block you can least afford to have rewritten by a model in a hurry. If the fence survives a deliberately hostile prompt, extend it to the rest of the docs directory.

Keep the fences. Swap the model whenever a better or cheaper one appears, and let the file format carry the guarantee instead of your memory of the prompt.