Generated documentation usually fails after the merge, not at the moment a model produces the first draft. Mechanical claims age with every renamed flag, while judgment claims age only when a decision actually changes. A single regenerate-all pass therefore overwrites the wrong sentences or leaves the right ones stale. The workable protocol is a split refresh: one queue the model may redraft, and one queue a human must rewrite.
This article treats that split as a documentation-generation workflow rather than as a style preference. It defines claim classes, a section annotation convention, and a queue builder that reads a git diff. The builder emits two files a reviewer can act on without rereading the entire guide. A constructed example later maps changed source paths onto queued headings without claiming production metrics.
Why a blob refresh fails in practice
Most teams regenerate an entire markdown file when a related source path changes in the same pull request. That habit collapses two different failure modes into one edit, which hides the more expensive error from reviewers. Mechanical drift is visible when a flag string no longer exists; judgment drift stays invisible until someone follows a rationale that production already abandoned.
A blob refresh also trains reviewers to skim, because the pull request now contains one undifferentiated wall of generated prose. Once a model rewrites the threat-model section beside the flag table, ownership becomes a sea of green. The cheaper move is to never send the threat-model section to the model after a purely mechanical change.
Four patterns show up in review logs even when a team has not collected a large quantitative sample.
- Flag tables that still document removed switches after a CLI refactor lands.
- Copy-pasted rationale paragraphs that survive a decision reversal without a named owner.
- Example commands that were regenerated while the surrounding operational warning was left untouched.
- Security assumptions rewritten in fluent prose that no accountable reviewer actually signed.
The queue split attacks those four patterns directly instead of hoping a later edit will catch them. Mechanical headings enter the model queue after a related diff. Decision, policy, and warning headings enter the human queue even when neighboring tables are stale. Ambiguous headings default to the human queue, because a missed mechanical refresh is cheaper than an unauthorized rewrite of a guarantee.
Claim classes that decide the queue
Treat every documentation heading as a claim with a class, not as a blob of helpful text. The class is a review contract: it says who may change the sentences under that heading after a source diff. Keep the taxonomy small enough that authors can apply it without forming a committee for each file.
| Claim class | Typical heading | After a related code diff | Queue |
|---|---|---|---|
| mechanical | Flags, paths, status codes, env vars | Model may redraft from the cited files | model_refresh |
| executable | Runnable examples, curl recipes | Model may propose; a test must run them |
model_refresh plus CI |
| rationale | Why this design, why not the alternative | Human rewrites or confirms unchanged | human_rewrite |
| guarantee | SLA, threat model, data retention, support | Human rewrites; model context is denied | human_rewrite |
| migration | Breaking-change notes, rollback, dual-run | Human rewrites from the release ticket | human_rewrite |
| unknown | Anything unclassified | Default to human | human_rewrite |
Executable examples sit in the model queue only because their truth is checkable against a command. If the example cannot be executed in CI, reclassify it as unknown and keep it off the model. Guarantees never enter the model queue, even when the surrounding file is mostly mechanical, because fluency is not ownership and a smooth paragraph can still be false.
Annotate headings in the document itself
A mapping file that lives far from the prose will drift the first time someone adds a heading. Put a one-line annotation under each heading so the queue builder and the human author share the same signal. HTML comments survive most static-site pipelines and stay invisible in the rendered page that readers actually see.
## CLI flags
<!-- claim-class: mechanical sources: src/cli/flags.go, src/cli/help.go -->
`--region` selects the partition. `--json` writes machine-readable output.
## Why the CLI is region-scoped
<!-- claim-class: rationale sources: docs/adrs/adr-014-region-scope.md -->
Region scope exists so a single credential cannot mutate every partition.
Numbered authoring rules keep the comments boring, parseable, and hard to bikeshed during review.
- Place the comment on the line immediately after the heading, never before the heading text.
- Use only
mechanical,executable,rationale,guarantee,migration, orunknown. - List
sourcesas repository-relative paths, comma-separated, with no globs inside the comment. - If a heading has no comment, the builder assigns
unknownand routes that heading to the human queue.
Globs belong in the mapping file, not in the document, because authors should name the files they actually read. A glob in a comment looks precise and then silently matches a generated path that no reviewer opened.
Keep a mapping file for path-to-doc edges
The comment names sources for a heading after a human already opened the file. The mapping file names the reverse edge: which source globs can dirty which documents when git reports a path. Both are required, because a git diff speaks in paths, while reviewers speak in headings and need a short list.
{
"default_class": "human_rewrite",
"documents": [
{
"path": "docs/cli.md",
"globs": ["src/cli/**", "cmd/root.go"]
},
{
"path": "docs/security.md",
"globs": ["src/auth/**", "src/tokens/**"]
}
]
}
The mapping does not repeat claim classes, and that omission is deliberate rather than incomplete. Classes stay in the markdown so a docs-only edit can change ownership without touching a second file. The JSON file answers a narrower question: given this diff, which documents must be scanned for annotated headings.
Build the two queues from a git diff
The following script is a constructed stdlib-only example, and it is not a production service with measured throughput. Label it as a starting harness, then add tests before you enforce it on protected branches. The only policy it encodes is routing: mechanical and executable headings may be drafted, and every other class must not.
Step 1 — Save the builder
#!/usr/bin/env python3
"""Emit model_refresh and human_rewrite queues from a git diff."""
from __future__ import annotations
import argparse
import fnmatch
import json
import re
import subprocess
from collections import defaultdict
from pathlib import Path
HEADING_RE = re.compile(r"^(#{2,6})\s+(.*\S)\s*$")
CLASS_RE = re.compile(
r"<!--\s*claim-class:\s*(\w+)(?:\s+sources:\s*([^>]*))?\s*-->"
)
MODEL_CLASSES = {"mechanical", "executable"}
def git_changed_files(since: str) -> list[str]:
out = subprocess.check_output(
["git", "diff", "--name-only", since],
text=True,
)
return [line.strip() for line in out.splitlines() if line.strip()]
def load_map(path: Path) -> dict:
return json.loads(path.read_text(encoding="utf-8"))
def docs_hit_by_diff(mapping: dict, changed: list[str]) -> list[str]:
hits: list[str] = []
for doc in mapping.get("documents", []):
for glob in doc["globs"]:
if any(fnmatch.fnmatch(item, glob) for item in changed):
hits.append(doc["path"])
break
return hits
def parse_sections(markdown: str) -> list[dict]:
lines = markdown.splitlines()
sections: list[dict] = []
current = None
for index, line in enumerate(lines):
heading = HEADING_RE.match(line)
if heading:
current = {
"level": len(heading.group(1)),
"title": heading.group(2).strip(),
"class": "unknown",
"sources": [],
"start": index,
}
sections.append(current)
continue
if current is None:
continue
comment = CLASS_RE.search(line)
if comment and current["class"] == "unknown":
current["class"] = comment.group(1)
raw = comment.group(2) or ""
current["sources"] = [p.strip() for p in raw.split(",") if p.strip()]
return sections
def classify(section: dict) -> str:
if section["class"] in MODEL_CLASSES:
return "model_refresh"
return "human_rewrite"
def write_queue(path: Path, rows: list[dict], queue_name: str) -> None:
path.parent.mkdir(parents=True, exist_ok=True)
lines = [
f"# {queue_name}",
"",
"Constructed queue. Confirm each heading before any model call.",
"",
]
if not rows:
lines.append("_Empty._")
for row in rows:
sources = ", ".join(row["sources"]) or "(none listed)"
lines.extend(
[
f"## {row['title']}",
"",
f"- File: `{row['doc']}`",
f"- Class: `{row['class']}`",
f"- Sources: {sources}",
"",
]
)
path.write_text("\n".join(lines) + "\n", encoding="utf-8")
def main() -> None:
parser = argparse.ArgumentParser()
parser.add_argument("--since", default="HEAD~1")
parser.add_argument("--map", default="doc-refresh.json")
parser.add_argument("--out", default=".refresh-queues")
args = parser.parse_args()
mapping = load_map(Path(args.map))
changed = git_changed_files(args.since)
queues = defaultdict(list)
for doc_path in docs_hit_by_diff(mapping, changed):
text = Path(doc_path).read_text(encoding="utf-8")
for section in parse_sections(text):
queues[classify(section)].append(
{
"doc": doc_path,
"title": section["title"],
"class": section["class"],
"sources": section["sources"],
}
)
out = Path(args.out)
write_queue(out / "model_refresh.md", queues["model_refresh"], "model_refresh")
write_queue(out / "human_rewrite.md", queues["human_rewrite"], "human_rewrite")
print(f"model_refresh={len(queues['model_refresh'])}")
print(f"human_rewrite={len(queues['human_rewrite'])}")
if __name__ == "__main__":
main()
Step 2 — Add a failing unit check around the classifier
Keep these tests in test_refresh_queues.py so the policy cannot silently invert during a later refactor. They do not prove that a redraft is factually correct; they only prove the heading was allowed into the intended queue.
from refresh_queues import classify
def test_unknown_headings_never_enter_the_model_queue():
section = {"class": "unknown", "title": "Notes", "sources": []}
assert classify(section) == "human_rewrite"
def test_mechanical_headings_enter_the_model_queue():
section = {
"class": "mechanical",
"title": "CLI flags",
"sources": ["src/cli/flags.go"],
}
assert classify(section) == "model_refresh"
def test_guarantee_headings_stay_on_the_human_queue():
section = {
"class": "guarantee",
"title": "Token lifetime",
"sources": ["src/auth/tokens.go"],
}
assert classify(section) == "human_rewrite"
Those three tests encode the only non-negotiable policy in the harness. Unknown is not an invitation to generate missing prose. Mechanical is not an invitation to skip a human skim of the identifiers. Guarantee is not a special case that a fluent model is allowed to paraphrase.
Step 3 — Run it against the last merge
python3 refresh_queues.py --since HEAD~1 --map doc-refresh.json
cat .refresh-queues/model_refresh.md
cat .refresh-queues/human_rewrite.md
Read both files before any drafting tool is invoked on the repository. If human_rewrite.md is not empty, open tickets or assign reviewers from CODEOWNERS for those headings only. Do not paste the human file into a prompt in order to finish the pull request faster.
Step 4 — Publish the queues from CI without merge-blocking on a model
Pin action versions and Python versions to whatever the GitHub Marketplace and your support window currently list. The workflow below is a shape, not a claim about a particular marketplace release.
name: doc-refresh-queues
on:
pull_request:
paths:
- "src/**"
- "docs/**"
- "doc-refresh.json"
- "refresh_queues.py"
jobs:
queues:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- uses: actions/setup-python@v5
with:
python-version: "3.12"
- name: Build refresh queues
run: python3 refresh_queues.py --since origin/${{ github.base_ref }}
- name: Upload queues
uses: actions/upload-artifact@v4
with:
name: refresh-queues
path: .refresh-queues
CI here does not merge-block on a model completing a draft, which would couple documentation truth to generator availability. It publishes the two queues so the review checklist cannot pretend the whole file is equally draftable. Reviewers then spend time on the human queue and only spot-check identifiers in the model queue.
Where a free model and a free server fit
Only the model_refresh file should ever be sent to a drafting model after the queues exist. The human queue is a work list, not a prompt, and mixing the two files recreates blob refresh with nicer headings. Free model access is enough for mechanical redrafts because the input is bounded: one heading, its listed sources, and a requirement to quote identifiers from those sources. A free server option is enough to host the queue builder and the drafting job, because the workload is a short git diff plus a handful of markdown files rather than a standing index of the whole repository.
Disclosure: This article was prepared as part of MonkeyCode's product outreach.
If you use that free model access, pass the model queue and the cited source files only, and keep guarantee headings out of the context window. The server does not change the ownership rule; it only removes the need to provision a private host for a job that should stay small. Run the queue builder on one recent merge before wiring any model to the output, and compare the two files with the headings reviewers actually touched.
Constructed walkthrough
Suppose src/cli/flags.go changes and src/auth/tokens.go does not change in the same merge. The mapping marks docs/cli.md dirty and leaves docs/security.md clean, which already prevents a security rewrite that nobody requested. Inside docs/cli.md, CLI flags is mechanical and Why the CLI is region-scoped is rationale. The builder therefore lists the flags heading in model_refresh.md and the rationale heading in human_rewrite.md.
The model may rewrite the flags table from flags.go and must fail closed if an identifier cannot be found in that file. The human must either rewrite the rationale or record that the decision is unchanged, with a name on the change. Mixing those two edits in one undifferentiated generation pass is the failure mode this workflow exists to prevent, and the queues make that mix visible before anyone reviews prose.
Limitations
The comment parser is line-oriented and will miss classes wrapped in shortcode fences or MDX components that the regex never sees. Path globs cannot see generated files that are not committed, so API reference trees built only in CI need a separate pipeline. The script treats every heading in a dirty document as potentially stale, which over-queues on large guides that share a source glob. It also cannot prove that a mechanical redraft is correct; it only proves that the heading was allowed into the model queue.
Defaulting unknown to the human queue will annoy teams that leave most headings unclassified for months. That annoyance is the point of the default, not a defect to configure away on day one. Classification is the cost of letting a model touch production documentation after a merge, and unpaid classification work will show up as an oversized human queue.
Who should not use this
Do not use this workflow on a single-page README whose entire contents are mechanical and already generated from --help. Do not use it for regulated documents that forbid any model-authored sentence, because the model queue would be a policy violation rather than a convenience. Do not use it when CODEOWNERS cannot name a human for guarantee headings, because the human queue then becomes a graveyard of unassigned work. Do not send the human queue to a model just to save time, which collapses the split and returns the team to blob refresh.
Teams that already generate API reference from source should keep that pipeline, and should not route those pages through this heading classifier. This harness is for narrative guides that mix tables with decisions, not for generated symbol lists that have no rationale sections. If a repository has no merge discipline around docs paths, the mapping file will be empty and both queues will lie.
Close the loop on the next merge
Run the queue builder on one recent merge and compare the two files with what reviewers actually edited in that pull request. If mechanical headings sat in the human queue, the comments are missing and authors need a lint, not a longer prompt. If guarantee headings sat in the model queue, the classifier is wrong and should fail closed until the annotation is fixed. Keep the model on the mechanical side of that line, and keep a named human on the side that still requires a decision.
Top comments (0)