DEV Community

Avery Lin
Avery Lin

Posted on

Regenerate Only What Moved: An Anchor Manifest for Model-Drafted Docs

Most documentation pipelines that use a model regenerate the whole page, and that is where both the risk and the cost live. A page is a bundle of claims with different owners, different evidence, and very different lifetimes. Regenerating a page to fix one stale paragraph quietly rewrites five paragraphs that were already correct and already approved. The fix is structural: bind every claim to the code that justifies it, then regenerate only the claims whose evidence actually moved.

The workflow below is small enough to adopt in an afternoon and specific enough to argue with. It has an anchor manifest, a four-state drift plan, a drafting step where a model is allowed to write, and a review queue where it is not. Every number this workflow reports comes from your own repository, which is the only honest source of such numbers.

The failure mode, stated precisely

A diff of a regenerated page mixes three kinds of change, and reviewers cannot separate them from the rendered result. Some changes are real drift caused by a code change, some are sampling noise from the model, and some are the model's editorial taste applied to prose that was fine. Under a whole-page pipeline these three arrive in one lump, so the reviewer either approves all of it or abandons the diff.

The second problem is cost asymmetry. A page with fourteen claims usually has one or two that are stale, so you spend model calls and human attention on twelve claims with nothing wrong with them. Repeated often enough, that pattern teaches reviewers to rubber-stamp, which is the worst outcome a documentation review process can produce.

The manifest approach removes both problems by making regeneration a targeted operation instead of a bulk one. You first prove which claims drifted, then you spend model time only there, and the reviewer sees a short, explainable queue instead of a page-sized diff.

Step 1: Anchor one claim per code reference

An anchor is an HTML comment on the line immediately above a claim, and it names the code location that would make the claim false if it changed. Keep the granularity tight; one anchor covers exactly one claim, never a section.

## Retry behavior

<!-- anchor: src/limiter/backoff.py#compute_backoff -->
Retries use exponential backoff with jitter and stop at the configured cap.
<!-- /anchor -->

<!-- anchor: src/limiter/backoff.py#MAX_ATTEMPTS -->
The limiter allows `MAX_ATTEMPTS` attempts per request.
<!-- /anchor -->
Enter fullscreen mode Exit fullscreen mode

The reference format is path#symbol, where the symbol is a function, a class, or a module-level constant. Claims with no defensible anchor are still allowed, but they are invisible to this pipeline and must be owned by a human explicitly. That gap is a feature: it forces you to notice which sentences no code can support.

Step 2: Build the manifest and classify drift

The script below parses anchored claims, resolves each anchor to a fingerprint, and compares that fingerprint against a committed manifest. Fingerprints hash the symbol body with per-line whitespace stripped, so a reformat or a reindent does not fake a drift event.

#!/usr/bin/env python3
"""anchordoc.py - plan documentation regeneration from code anchors.

Usage:
  python anchordoc.py plan   --docs docs/ --manifest docs.anchors.json
  python anchordoc.py accept --docs docs/ --manifest docs.anchors.json --owner avery
"""
from __future__ import annotations
import argparse, hashlib, json, re, subprocess
from datetime import datetime, timezone
from pathlib import Path

ANCHOR_RE = re.compile(r"<!--\s*anchor:\s*(?P<ref>[^\s>]+)\s*-->")
DEF_RE = re.compile(
    r"^(?P<indent>\s*)(?:(?:async\s+)?def|class)\s+(?P<fn>\w+)"
    r"|^(?P<const>[A-Z][A-Z0-9_]*)\s*="
)

def git_blob(path: Path) -> str:
    out = subprocess.run(["git", "hash-object", str(path)],
                         capture_output=True, text=True, check=True)
    return out.stdout.strip()

def symbol_fingerprint(path: Path, symbol: str) -> str | None:
    lines = path.read_text(encoding="utf-8").splitlines()
    for i, line in enumerate(lines):
        m = DEF_RE.match(line)
        if not m or (m.group("fn") or m.group("const")) != symbol:
            continue
        body = [line]
        if m.group("fn"):                      # block ends at first dedent
            indent = len(m.group("indent"))
            for nxt in lines[i + 1:]:
                if nxt.strip() and len(nxt) - len(nxt.lstrip()) <= indent:
                    break
                body.append(nxt)
        norm = "\n".join(b.strip() for b in body if b.strip())
        return hashlib.sha256(norm.encode()).hexdigest()[:16]
    return None

def parse_claims(doc: Path) -> list[dict]:
    text = doc.read_text(encoding="utf-8")
    claims, cursor = [], 0
    for m in ANCHOR_RE.finditer(text):
        if claims:
            claims[-1]["text"] = text[claims[-1]["start"]:m.start()].strip()
        claims.append({"anchor": m.group("ref"), "start": m.end()})
    if claims:
        claims[-1]["text"] = text[claims[-1]["start"]:].strip()
    return claims

def resolve(ref: str, repo: Path):
    rel, _, symbol = ref.partition("#")
    src = repo / rel
    if not rel or not symbol or not src.exists():
        return None
    return symbol_fingerprint(src, symbol)

def main() -> None:
    ap = argparse.ArgumentParser()
    ap.add_argument("mode", choices=["plan", "accept"])
    ap.add_argument("--docs", required=True, type=Path)
    ap.add_argument("--manifest", required=True, type=Path)
    ap.add_argument("--owner", default="unassigned")
    args = ap.parse_args()

    manifest = json.loads(args.manifest.read_text()) if args.manifest.exists() else {}
    repo = Path.cwd()
    plan = {"fresh": [], "stale": [], "orphan": [], "new": []}

    for doc in sorted(args.docs.rglob("*.md")):
        for claim in parse_claims(doc):
            key = f"{doc.as_posix()}|{claim['anchor']}"
            fp = resolve(claim["anchor"], repo)
            if fp is None:
                plan["orphan"].append({"key": key, "anchor": claim["anchor"]})
                continue
            entry = manifest.get(key)
            if entry is None:
                plan["new"].append({"key": key, "fingerprint": fp})
            elif entry["fingerprint"] != fp:
                plan["stale"].append({"key": key, "was": entry["fingerprint"],
                                      "now": fp, "class": entry.get("class")})
            else:
                plan["fresh"].append({"key": key})
            if args.mode == "accept":
                manifest[key] = {"fingerprint": fp, "owner": args.owner,
                                 "class": entry.get("class") if entry else "behavior",
                                 "approved_at": datetime.now(timezone.utc)
                                 .strftime("%Y-%m-%dT%H:%M:%SZ")}

    if args.mode == "accept":
        args.manifest.write_text(json.dumps(manifest, indent=2, sort_keys=True) + "\n")
        print(f"accepted {len(manifest)} claims into {args.manifest}")
    else:
        plan["counts"] = {k: len(v) for k, v in plan.items()}
        print(json.dumps(plan, indent=2))

if __name__ == "__main__":
    main()
Enter fullscreen mode Exit fullscreen mode

This is a reference implementation, not a blessed one; run it on a scratch branch before you trust its plan output. The two commands that matter are the plan run and the human-only accept run.

python tools/anchordoc.py plan   --docs docs/ --manifest docs.anchors.json > regenerations.json
python tools/anchordoc.py accept --docs docs/ --manifest docs.anchors.json --owner avery
Enter fullscreen mode Exit fullscreen mode

A plan output has a predictable shape, and the counts line is the one you read first. If stale is consistently a small fraction of fresh, your anchors are doing their job and your regeneration budget is small.

{
  "fresh":  [{ "key": "docs/retries.md|src/limiter/backoff.py#compute_backoff" }],
  "stale":  [{ "key": "docs/retries.md|src/limiter/backoff.py#MAX_ATTEMPTS", "was": "3b91c0d7a2e4f1aa", "now": "9f2c1ab34d5e6f70", "class": "behavior" }],
  "orphan": [{ "key": "docs/limits.md|src/limiter/budget.py#spend", "anchor": "src/limiter/budget.py#spend" }],
  "new":    [{ "key": "docs/limits.md|src/limiter/budget.py#reset", "fingerprint": "77aa1c90d2be4410" }],
  "counts": { "fresh": 41, "stale": 2, "orphan": 1, "new": 0 }
}
Enter fullscreen mode Exit fullscreen mode

Step 3: Read the four states as four different jobs

Drift is not one condition, and collapsing the states is how teams end up regenerating things that were never broken. Treat each state as a distinct task with a distinct owner.

State Meaning Next action Who acts
fresh Fingerprint matches the approved manifest Do nothing; do not send it to a model Nobody
stale Source symbol changed since approval Draft a replacement, then route by claim class Model drafts, human signs
orphan Anchor no longer resolves Fix the manifest or the claim by hand Human
new Anchored claim with no approved baseline Review the text once, then accept it Human

The most important row is fresh, because the correct action there is inaction. Any pipeline that touches fresh claims has already given up the main benefit of this design. Orphans deserve a second look as well: a rename looks exactly like a deletion, so a two-line manifest edit is usually the whole fix.

Step 4: Draft only the stale claims

Now the model gets a narrow job with real evidence attached: one claim, one symbol body, and an instruction to prefer silence over invention. The drafting step writes to a separate directory and never overwrites the approved document.

This is where a free model tier becomes genuinely useful, because the calls are small, scoped, and easy to discard. I use MonkeyCode's free model access for exactly this step; the drafting call stays a thin wrapper so the endpoint can be swapped without touching the plan logic.

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

jq -r '.stale[] | [.key, .class] | @tsv' regenerations.json \
| while IFS=$'\t' read -r key class; do
    python tools/draft_claim.py --key "$key" --class "$class" \
      --out "docs/drafts/$(echo "$key" | cut -d'|' -f1 | xargs basename)"
  done
Enter fullscreen mode Exit fullscreen mode

The prompt skeleton matters more than the transport, and the escape hatch on the last line is deliberate. A model that is allowed to answer UNCHANGED stops inventing filler after a refactor that did not change behavior.

You may rewrite only the claim text below, using the source as ground truth.
Do not introduce numbers, dates, version strings, guarantees, or new capabilities.
Return exactly one paragraph of prose, or the token UNCHANGED.

CLAIM:
{claim_text}

SOURCE:
{symbol_body}
Enter fullscreen mode Exit fullscreen mode

Step 5: Route drafts by claim class, not by confidence

A model's self-reported confidence is not a routing signal, but the class of claim is. The table below is the part of this workflow I would defend hardest, because it encodes who can be blamed when a sentence is wrong.

Claim class Example Model may draft? Human must own
Behavior narrative "Retries back off exponentially until the cap" Yes, when stale Doc owner confirms the code path
Numeric limits and timeouts "The cap is 30 seconds" Yes, but the number must be grepped from source Reviewer verifies the value in the diff
Versions and deprecation dates "Removed in 3.0" No Release owner
Security, privacy, licensing "Tokens are never logged" No Named role, not the doc owner
Setup and migration commands make migrate-dry-run Yes Someone runs every command before merge
API signatures Function parameters and types No, generate from introspection Tooling, then a spot check
Intent and roadmap "We plan to consolidate this" No anchor can support it Product owner, outside this pipeline

The numeric row is the subtle one: the model may write the sentence, but the number has to come from the source, not from the model's summary of it. The intent row explains why some paragraphs must be marked as unanchored on purpose, rather than given a plausible anchor that no code can justify.

Step 6: Schedule the planning run, not the drafting run

Running the planner on a schedule is cheap and safe, because it only reads code and writes a JSON plan. The drafting loop should stay manual until your approval rate stabilizes, since a scheduled drafting loop mostly produces orphan drafts nobody reads.

A free server option is a reasonable home for the planning job, since the work is a short weekly read over a checkout. Treat that availability as convenience rather than a dependency, and keep the job off your release critical path entirely.

# weekly, 07:00 UTC - plan only, no writes to docs/
0 7 * * 1 cd /srv/repo && git pull --ff-only && \
  python tools/anchordoc.py plan --docs docs/ --manifest docs.anchors.json \
  > reports/regen-$(date +%F).json
Enter fullscreen mode Exit fullscreen mode

What to measure every week

Three numbers tell you whether the workflow is working, and all three come straight out of the plan output. Stale ratio is stale / (stale + fresh); orphan count is the number of anchors that no longer resolve; median staleness age is how long stale claims waited for an owner. Add one process number on top: regeneration precision, the share of model drafts a reviewer accepted without substantive edits.

If precision drops while the stale ratio holds steady, your drafting step is the problem, not your anchors. If the orphan count grows every week, your anchors are too fine-grained for the refactor rate of the code they track. Neither signal requires a new tool; both require a decision about claim granularity.

Limitations and who should skip this

The fingerprint detects change, not meaning, so a comment-only commit or a pure rename-preparation refactor can flip a claim to stale with no semantic drift. Expect false positives and budget a few minutes per week to dismiss them. The symbol extraction here is Python-shaped and will miss decorators with multi-line bodies, dynamically created methods, and most non-Python languages without a rewrite.

A fresh fingerprint is not a correctness guarantee, and this is the limitation that matters most. A sentence that was wrong when it was accepted stays wrong forever if the code behind it never changes, which is why the new state needs a real human read rather than a rubber stamp.

Skip this workflow if your claims live outside the repository, such as pricing pages, SLAs, or anything legal reviews. Skip it if you have no git-tracked version of record, since the manifest is meaningless without one. Skip it as well if nobody can spare a reviewer for the human queue, because this design trades model time for human attention rather than the reverse.

If your docs already live beside your code, the manifest is a one-time afternoon of annotation and a weekly read afterwards. Start with the behavior class only, measure regeneration precision for two weeks, and widen the scope only when reviewers stop rewriting the drafts.

Top comments (0)