Freeze the Human Lane: A Two-Directory Doc Workflow for Model-Drafted Pages
The cheapest way to keep generated documentation honest is not another review checklist; it is refusing to let the model edit sentences it is not allowed to be wrong about. Most teams already accept that a model can draft prose, but they keep the diff open across the whole page, so every draft is a silent negotiation over facts nobody meant to renegotiate. This article describes a two-directory workflow that splits each page into lanes, freezes the human lane with byte-level diffs, and routes model disagreements into sidecar proposal files. The artifact is a small Python checker you can run locally or in CI, and the whole loop is small enough to execute on a free-tier workspace.
The rule: three lanes, one writer each
Every paragraph on a page belongs to exactly one lane, and the lane decides who may write it. The model lane holds prose whose worst failure is awkward wording: overviews, transitions, motivation, and the connective tissue around examples. The human lane holds claims whose failure costs money, safety, or a support ticket: limits, pricing, security posture, deprecation, and the reason a design exists. The third lane is not prose at all; it is generated output such as --help text or a JSON schema, which the model may quote but never author.
The useful test is not "is this technical?" but "who absorbs the cost if this sentence is wrong?" If a wrong sentence would produce a false support answer, a failed migration, or a compliance problem, it belongs to a human owner with a review date. If a wrong sentence would merely read badly, the model may own it and the reviewer can move on quickly. That single question resolves most arguments about scope before anyone opens an editor.
| Content on the page | Lane | Writer |
|---|---|---|
| Page overview, transitions, "why this matters" | model | Model, reviewed at PR time |
| Retry caps, timeouts, quotas, pricing, SLAs | human | Named maintainer, dated |
| Security warnings and data-handling notes | human | Security reviewer, dated |
| CLI flags, exit codes, JSON schema | generated | Build step, quoted verbatim |
| Changelog entries and version pins | generated | Release tooling |
Step 1 — Mark the frozen regions
The marker format matters less than the invariant it encodes: a human block that changes in place is a bug, not an edit. Use HTML comments so the markers disappear in rendered Markdown and survive most toolchains unchanged. Keep one block per claim cluster, because a large frozen block makes honest updates painful and invites people to delete markers.
<!-- owner:human id=retry-budget review-by=2027-02-01 --> Retries are capped at five attempts per request, with exponential backoff starting at 200 ms. The sixth attempt returns the original error unchanged. <!-- /owner --> <!-- owner:model id=retry-overview --> The client retries transient failures on your behalf, so most callers never see a raw 503 from the upstream service. <!-- /owner --> The review-by date is the part teams skip, and it is the part that keeps the freeze from becoming a fossil. An expired date does not block the build by default; it prints a warning so the owner sees the block on their next pass. Start with a six-month horizon and shorten it for anything that tracks a vendor's behaviour.
Step 2 — Enforce the freeze with a diff checker
The checker below compares each human block between the merge base and the working tree, using line-level equality rather than a fuzzy similarity score. Fuzzy matching is tempting and it is exactly how a rewritten number slips through with a high similarity ratio. Line equality is blunt, boring, and correct for this purpose.
#!/usr/bin/env python3 """doc_lanes.py - refuse in-place model edits to human-owned doc blocks.""" import subprocess import sys from pathlib import Path def read_marker(line): s = line.strip() if not (s.startswith("<!--") and s.endswith("-->")): return None inner = s[4:-3].strip() if inner == "/owner": return {"kind": "close"} if not inner.startswith("owner:"): return None head, _, rest = inner.partition(" ") marker = {"kind": "open", "owner": head.split(":", 1)[1]} for token in rest.split(): key, _, value = token.partition("=") marker[key] = value if "id" not in marker: raise SystemExit("open marker without id: " + line) return marker def parse_blocks(lines): blocks, stack = {}, [] for index, line in enumerate(lines): marker = read_marker(line) if marker is None: continue if marker["kind"] == "open": marker["start"] = index stack.append(marker) else: if not stack: raise SystemExit("close marker without open at line " + str(index + 1)) block = stack.pop() block["end"] = index blocks[block["id"]] = block if stack: raise SystemExit("unclosed block: " + stack[-1]["id"]) return blocks def lanes_at(rev, path): shown = subprocess.run(["git", "show", rev + ":" + path], capture_output=True, text=True) if shown.returncode != 0: return {}, [] lines = shown.stdout.splitlines() return parse_blocks(lines), lines def main(): base = sys.argv[1] if len(sys.argv) > 1 else "origin/main" diff = subprocess.run(["git", "diff", "--name-only", base, "--", "docs"], capture_output=True, text=True, check=True) failures = [] for path in filter(None, diff.stdout.splitlines()): old_blocks, old_lines = lanes_at(base, path) head_lines = Path(path).read_text(encoding="utf-8").splitlines() new_blocks = parse_blocks(head_lines) for block_id, old in old_blocks.items(): if old["owner"] != "human": continue if block_id not in new_blocks: failures.append(path + ": human block removed: " + block_id) continue new = new_blocks[block_id] old_slice = old_lines[old["start"]:old["end"] + 1] new_slice = head_lines[new["start"]:new["end"] + 1] if old_slice != new_slice: failures.append(path + ": human block rewritten in place: " + block_id) for line in failures: print("FAIL " + line) if failures: print("Write a proposal under docs/_proposals/ instead of editing these blocks.") sys.exit(1) print("doc lanes OK") if __name__ == "__main__": main() Run it against your own last doc PR before wiring CI, because a repo that has never used markers will fail on the first run with dozens of removed-block errors. That first run is the migration: add markers around the claims that matter and leave the rest unfrozen for now. Partial coverage beats a freeze nobody can maintain.
Step 3 — Give the model somewhere else to write
A frozen block needs an escape hatch, or contributors will edit around the checker. The convention is docs/_proposals/<page>/<block-id>.md, where the model writes its proposed replacement plus the source it believes justifies the change. The proposal is a normal file in the same PR, so reviewers see the disagreement without the authoritative page moving underneath them.
mkdir -p docs/_proposals/retries # the model writes here, never into the frozen block team-workspace draft --page docs/retries.md --out docs/_proposals/retries/retry-budget.md python3 scripts/doc_lanes.py origin/main This is the step that changes reviewer behaviour. Instead of reading a diff and hunting for what moved, the reviewer reads a short proposal and decides whether to accept it. Accepting means the human edits the block and updates the review-by date, which serialises the change through the owner. Rejecting means deleting the proposal file, which is a one-line diff and needs no explanation.
Step 4 — Let the freeze decay on purpose
Stale freezes are the predictable failure of this design, so make expiry visible in the same output as the lane check. The snippet below compares each block's review-by value against today's ISO date and prints anything past due, including blocks whose date is missing. String comparison is safe here because ISO dates sort lexicographically.
from datetime import date from pathlib import Path today = date.today().isoformat() for page in Path("docs").rglob("*.md"): lines = page.read_text(encoding="utf-8").splitlines() for block in parse_blocks(lines).values(): if block["owner"] != "human": continue if block.get("review", "") < today: print(page, block["id"], "review-by", block.get("review", "missing")) Treat the output as a monthly chore list rather than a build gate. A build gate on expiry teaches people to bump dates without reading, which is worse than no date at all. A chore list keeps the owner in the loop and lets a maintainer deprioritise a block honestly.
Step 5 — Wire it into the pipeline
The GitHub Actions job is short because all the logic lives in the script. Keep fetch-depth: 0 so the base ref is present, and pass the base branch through so the same script works on local runs and PRs.
name: doc-lanes on: pull_request jobs: lanes: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 with: fetch-depth: 0 - run: python3 scripts/doc_lanes.py origin/${{ github.base_ref }} Where the drafting pass runs
I run the draft pass and the checker in the same free workspace rather than on a local machine, because the loop is diff-heavy and a contributor without a fast laptop should still be able to run it. MonkeyCode provides free model access and a free server option for this kind of small, repeatable job; the operator's current post states a free allowance of 10 million tokens, and you should confirm the live quota on the project page before planning capacity around it. Disclosure: This article was prepared as part of MonkeyCode's product outreach.
The product's role here is deliberately narrow: it supplies the model pass that fills model blocks and the workspace that executes the checker. It does not decide lane ownership, it does not approve proposals, and it should never be the writer of a human block. Teams that treat free access as a reason to skip the freeze will get exactly the failure this workflow exists to prevent.
What this does not catch, and who should skip it
The checker verifies that frozen bytes did not move; it says nothing about whether an unchanged sentence was ever true. It cannot detect a number that was wrong before the freeze, and it will not tell you that a block should have been frozen in the first place. Nested markers, renamed block ids, and pages generated at build time also need manual handling, so budget an afternoon for the first migration.
Skip this workflow if your docs are a single page, if you cannot add a CI check, or if a model already writes whole pages that a senior maintainer reads line by line. It also fits poorly where every sentence is regulated, because a page with ninety percent frozen blocks is a page the model should not draft at all. Use it when you have enough prose to automate and enough hard claims to protect.
If you want to test the loop, clone the project, add markers to two pages, and run the checker against your last doc PR before you trust any draft.