DEV Community

Avery Lin
Avery Lin

Posted on

Churn-Gated Doc Drafting: Let the Model Write Only Sections Whose API Surface Has Settled

A model should draft a documentation section only when the exported surface that section describes has stopped moving, and every other section should stay human-owned with an explicit anchor. That single rule replaces the vague question "what can the model write?" with two measurements you can compute from git and from your compiler's own output. The workflow below turns those measurements into a gate that runs in CI, so drafting rights are decided by data instead of by whoever is impatient that afternoon.

The core insight is that documentation risk tracks API volatility far more than it tracks prose difficulty. A glossary paragraph about a service that shipped three breaking changes this quarter is riskier than a nuanced explanation of a function that has not changed in a year. Most teams already have the evidence for that asymmetry in their commit history; they simply never join it to their docs.

Step 1: Write the anchor map by hand, once

The anchor map is the human-owned input to the whole system, and it is the only file a model is never allowed to edit. Each section of each page points at one symbol and one source file, and it declares an ownership status.

# docs/anchors.yml
pages:
  rate-limits.md:
    sections:
      retry-policy:
        package: "./pkg/retry"
        symbol: "pkg/retry.Client.Do"
        file: "pkg/retry/client.go"
        status: drafted
      error-codes:
        package: "./pkg/retry"
        symbol: "pkg/retry.ErrCodes"
        file: "pkg/retry/errors.go"
        status: human
        human_notes: "Error strings are contractual; only the owner edits this table."
Enter fullscreen mode Exit fullscreen mode

The status field has exactly two legal values, drafted and human, and demoting a section is always allowed while promoting one requires a clean gate run. The human_notes field is where reviewers record the answer to any question the model raised, which keeps decisions next to the anchor instead of buried in a pull request thread. Keep this file small: one anchor per section, no wildcards, no inheritance.

Step 2: Fingerprint the public surface, not the file

A file hash is useless here, because formatting and comments churn constantly while the contract stays stable. Fingerprint the exported declarations instead, which requires filtering the tool output rather than hashing it raw.

#!/usr/bin/env bash
set -euo pipefail
pkg="$(go list ./pkg/retry)"
go doc -all "$pkg" \
  | grep -E '^(func|type|var|const|\t[A-Z][A-Za-z0-9_]*)' \
  | sed 's/[[:space:]]\+/ /g' \
  | sha256sum | cut -d' ' -f1
Enter fullscreen mode Exit fullscreen mode

The filter is the whole trick. go doc -all includes doc comments, so hashing it unfiltered makes the fingerprint change on every comment edit and the gate becomes noise within a week. Restricting the hash to declaration lines means a renamed parameter, a changed return type, or a new exported constant flips the fingerprint, while prose cleanup does not.

Step 3: Measure churn over a fixed window

Churn is a count of commits touching the defining file inside a rolling window, and it is deliberately cheap to compute.

git log --since="180 days ago" --pretty=format:%H -- pkg/retry/client.go | wc -l
Enter fullscreen mode Exit fullscreen mode

Use one window for the whole repository so the numbers stay comparable, and write the window into the gate output so reviewers see the basis of the decision. A threshold of 180 days works for libraries with quarterly releases and should shrink for services that deploy daily. The exact number matters less than applying it uniformly.

Step 4: Apply the drafting rights table

The table below is the decision surface, and it is the part you should argue about with your team before automating anything.

Churn (commits / window) Surface fingerprint Model may draft Human must own
0–2 unchanged since last receipt full section prose, glossary, cross-links final review, error semantics, examples
3–9 unchanged since last receipt intro paragraph, terminology, navigation prose tables, defaults, deprecation notes, anything numeric
≥10 any value outline only, labeled PROPOSAL the entire section
any value changed since last receipt nothing rewrite plus re-anchoring

Four categories stay human-owned no matter what the numbers say: error message meaning, permission and security boundaries, deprecation promises, and numeric limits. Those are the claims readers act on and the claims that generate support tickets when they drift. A model can restate them once a human has written them, but it should never be the origin.

Step 5: Enforce the drafting contract with refusal tokens

The prompt is short because the gate has already done the scoping work. The model receives one section, the source excerpt for that symbol, and a requirement to refuse rather than guess.

Draft exactly one documentation section.
Section id: {{section_id}}
Anchor symbol: {{symbol}} (surface fingerprint {{fp}})
Allowed output: prose only.
Forbidden: inventing flags, defaults, error strings, limits, or version numbers.
If a required fact is absent from the source excerpt, emit a line:
NEEDS_HUMAN: <one-line question>
Enter fullscreen mode Exit fullscreen mode

Every NEEDS_HUMAN line becomes a review task, and the reviewer's answer is appended to human_notes on the anchor so the next draft has it. This is where a free drafting tier pays off, because the volume of low-risk sections is high and the gate caps the downside of any single bad draft. In this workflow MonkeyCode's free model access covers the low-churn drafting tier, and its free server option is where the gate script can run if you do not want a laptop awake at night; verify current availability and terms yourself, since free offerings change.

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

Step 6: Fail the build on drift

The gate is a single script, and it fails for exactly two reasons: a drafted section whose surface fingerprint moved, or a drafted section that crossed the churn threshold.

#!/usr/bin/env python3
import hashlib, json, pathlib, subprocess, sys, yaml

def surface_fp(pkg):
    out = subprocess.run(["go", "doc", "-all", pkg], check=True,
                         capture_output=True, text=True).stdout
    keep = [l for l in out.splitlines()
            if l.startswith(("func", "type", "var", "const")) or l.startswith("\t")]
    norm = [ " ".join(l.split()) for l in keep if l.strip() ]
    return hashlib.sha256("\n".join(norm).encode()).hexdigest()

def churn(path, days=180):
    out = subprocess.run(["git", "log", f"--since={days} days ago",
                          "--pretty=%H", "--", path],
                         check=True, capture_output=True, text=True).stdout
    return len([l for l in out.splitlines() if l.strip()])

anchors = yaml.safe_load(pathlib.Path("docs/anchors.yml").read_text())
failures = []
for page, spec in anchors["pages"].items():
    rec_path = pathlib.Path("docs/.draft-receipts") / f"{page}.json"
    receipt = json.loads(rec_path.read_text()) if rec_path.exists() else {}
    for sec, a in spec["sections"].items():
        fp, c = surface_fp(a["package"]), churn(a["file"])
        prev = receipt.get(sec, {}).get("fingerprint")
        if a["status"] == "drafted":
            if prev and prev != fp:
                failures.append(f"{page}#{sec}: surface changed {prev[:8]} -> {fp[:8]}")
            if c >= 10:
                failures.append(f"{page}#{sec}: churn {c} >= 10, must be human-owned")
        receipt.setdefault(sec, {}).update(
            fingerprint=fp, churn=c, symbol=a["symbol"])
    rec_path.parent.mkdir(parents=True, exist_ok=True)
    rec_path.write_text(json.dumps(receipt, indent=2))
    print("\n".join(failures) or "anchors ok")
    sys.exit(1 if failures else 0)
Enter fullscreen mode Exit fullscreen mode

This is a reference implementation rather than a battle-tested tool, and it has a known rough edge: it rewrites receipts even when the run fails, so commit receipts explicitly or move the write behind a --record flag. The Go-specific filtering also needs a per-language equivalent, which for TypeScript means hashing declaration output instead of go doc.

Limitations and who should skip this

A fingerprint proves the declaration changed, not that the meaning changed, so a semantics-only edit in a function body will pass the gate while making the prose wrong. Churn is a proxy for volatility, not a measure of consequence, and a low-churn internal helper can still be load-bearing for thousands of readers. The gate also cannot see documentation that lives outside symbol-anchored prose, such as tutorials, migration guides, and runbooks, which need separate ownership rules.

Skip this approach if your API is pre-1.0, because almost everything will land in the human-owned row and the automation earns nothing. Skip it if your docs must carry a formal audit trail that a JSON receipt cannot satisfy, or if you cannot send source excerpts to a hosted model without a policy review. Skip it too if your team has no CI, since a gate that runs only when someone remembers is a suggestion rather than a gate.

Start narrow: pick one high-traffic page, add three anchors, and run the gate in report-only mode for two weeks to see how often fingerprints actually move. The threshold table will need adjusting, and the honest answer to "what may the model draft" will come from your own churn data rather than from any article, including this one.

Top comments (0)