DEV Community

Avery Lin
Avery Lin

Posted on

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 -->
Enter fullscreen mode Exit fullscreen mode

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()
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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"))
Enter fullscreen mode Exit fullscreen mode

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 }}
Enter fullscreen mode Exit fullscreen mode

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.

Top comments (0)