Dev.to AI 🤖 Ai 👁 0 📖 8 min read

Make Doc Drafting Idempotent: A Content-Addressed Cache and Stability Gate for Free-Tier Models

If you run a model over the same documentation twice and get two different diffs, you do not have a pipeline — you have a random number generator attached to your git history. The fix is not a better prompt; it is to mak

If you run a model over the same documentation twice and get two different diffs, you do not have a pipeline — you have a random number generator attached to your git history. The fix is not a better prompt; it is to make draft generation idempotent, so the same source inputs either produce the same bytes or produce nothing at all. This post builds that: a content-addressed draft cache, a cold-cache stability gate, and an ownership table that says exactly which parts of a page a model may draft and which parts a human must sign.

The pipeline is model-agnostic on purpose. It was designed for the awkward middle case that most teams actually live in: drafting prose with a free-tier model on a scheduled job, with no budget line and no dedicated reviewer. Free-tier availability is treated here only as an operator-supplied starting condition, never as a guarantee of throughput, latency, or permanence.

1. The failure mode: re-run churn

A nightly job drafts forty sections. On Monday it rewrites the wording of nineteen of them even though no source file changed, and reviewers learn to ignore the bot. That churn has three real costs: review attention burned on non-changes, git blame polluted with machine commits, and worse, a genuine correction buried inside cosmetic noise. None of those costs appear in a token bill, which is why the problem survives so long.

The root cause is that drafts have no identity. A section is regenerated from a prompt string, and the prompt string is not the input — the input is the prompt plus the source files it describes. Until you hash the actual inputs, you cannot tell "the model changed" apart from "the docs changed," and you will keep paying for both.

2. The artifact: a section contract and an input fingerprint

Start with a contract file that names each section, its sources, and a human owner. The owner is required, not optional, because the cache proves a draft is reproducible, not that it is correct.

# docs/contract.yml
prompt_version: "v3"
sections:
  - name: install
    mode: draft
    owner: "@avery"
    prompt: "Explain install prerequisites, then the three install paths."
    sources:
      - pyproject.toml
      - scripts/setup.sh
  - name: security-model
    mode: human-only
    owner: "@avery"
    prompt: ""
    sources: []

The fingerprint hashes the prompt version, the prompt text, and the byte content of every declared source. Change any of them and the fingerprint changes; change nothing and you get the same key back.

# docsgen/contract.py
import hashlib, pathlib
from dataclasses import dataclass

@dataclass(frozen=True)
class Section:
    name: str
    prompt: str
    sources: tuple[str, ...]
    owner: str
    mode: str  # "draft" | "human-only"

def fingerprint(sec: Section, prompt_version: str) -> str:
    h = hashlib.sha256()
    h.update(prompt_version.encode())
    h.update(sec.prompt.encode())
    for src in sorted(sec.sources):
        h.update(src.encode())
        h.update(pathlib.Path(src).read_bytes())
    return h.hexdigest()[:16]

Two properties matter here. First, the hash is stable across machines, so a laptop run and a server run agree. Second, human-only sections are declared in the same file, which means the pipeline can assert that no model output ever lands in them.

3. Five steps to an idempotent run

Step 1 — Cache on the fingerprint, not on the section name. A section name is a location; the fingerprint is a content identity. Store both, but key on the fingerprint so old drafts remain addressable after an edit.

# docsgen/cache.py
import sqlite3, pathlib

SCHEMA = """
CREATE TABLE IF NOT EXISTS drafts (
  fingerprint TEXT PRIMARY KEY,
  section     TEXT NOT NULL,
  body        TEXT NOT NULL,
  run_id      TEXT NOT NULL
);
"""

def open_cache(path=".docsgen/cache.sqlite3"):
    pathlib.Path(path).parent.mkdir(parents=True, exist_ok=True)
    con = sqlite3.connect(path)
    con.executescript(SCHEMA)
    return con

def get(con, fp):
    row = con.execute("SELECT body FROM drafts WHERE fingerprint = ?", (fp,)).fetchone()
    return row[0] if row else None

def put(con, fp, section, body, run_id):
    con.execute("INSERT OR REPLACE INTO drafts VALUES (?,?,?,?)", (fp, section, body, run_id))
    con.commit()

Step 2 — Default to diff, not write. The job prints a unified diff and only touches files when --write is passed. Dry-run is the normal mode for scheduled jobs; --write is for the run a human is watching.

Step 3 — Skip human-only sections explicitly. Assert their absence rather than trusting that nobody added one to a prompt list. A one-line check here prevents the worst class of regression, where a policy page quietly becomes machine-drafted.

# docsgen/run.py
import argparse, difflib, json, pathlib, uuid, yaml
from contract import Section, fingerprint
from cache import open_cache, get, put

def render(sec: Section) -> str:
    raise NotImplementedError("wire your model client here; return markdown")

def load(path="docs/contract.yml"):
    raw = yaml.safe_load(pathlib.Path(path).read_text())
    secs = [Section(**s) for s in raw["sections"]]
    return raw["prompt_version"], secs

def main():
    ap = argparse.ArgumentParser()
    ap.add_argument("--write", action="store_true")
    args = ap.parse_args()
    run_id, changed, spent = uuid.uuid4().hex[:8], [], 0
    version, secs = load()
    con = open_cache()
    for sec in secs:
        if sec.mode == "human-only":
            continue
        fp = fingerprint(sec, version)
        body = get(con, fp)
        if body is None:
            body = render(sec)
            spent += 1
            put(con, fp, sec.name, body, run_id)
        target = pathlib.Path(f"docs/{sec.name}.md")
        old = target.read_text() if target.exists() else ""
        if old == body:
            continue
        changed.append(sec.name)
        if args.write:
            target.write_text(body)
        else:
            print("".join(difflib.unified_diff(
                old.splitlines(True), body.splitlines(True), f"a/{target}", f"b/{target}")))
    print(json.dumps({"run_id": run_id, "changed": changed, "model_calls": spent}))

if __name__ == "__main__":
    main()

Step 4 — Emit a machine-readable run summary. The changed list and model_calls count are the only evidence a reviewer needs, and they make the stability gate below a two-line shell check instead of a bespoke script.

Step 5 — Fail the job when a second pass is not empty. This is the gate that turns an idempotent design into an enforced property.

python -m docsgen.run --write \
  && python -m docsgen.run | tee /tmp/pass2.json \
  && jq -e '.changed == []' /tmp/pass2.json

On a warm cache, pass two must report zero changes with zero model calls. If it reports changes, either the fingerprint is incomplete or something is writing to docs/ outside the pipeline; both are bugs worth stopping the build for.

4. The cold-cache audit you still need

The warm-cache gate proves determinism of your cache, not of the model, and those are different claims. Once a day, or once per prompt-version bump, run the same section twice against an empty cache and diff the two outputs. Three outcomes, three actions:

Cold-cache result What it means Action
Byte-identical Draft is stable enough to automate Keep it in the scheduled job
Differs in wording only Prose is unstable but claims agree Cache it, require human review on refresh
Differs in a claim, number, or code identifier Contract is underspecified Demote the section to human-only

That third row is why the audit is not optional. A wording difference is a review cost, while a changed number is an incident, and no amount of caching converts the second into the first.

5. Where a free model and a free server actually fit

MonkeyCode is an open-source coding agent project that the operator advertises with free model access and a free server option, and both matter for this specific design rather than as general convenience. The drafting call in render() is the only place a model appears, and the cache means a rate-limited or interrupted run resumes without re-spending tokens on sections already drafted. The free server hosts nothing but a scheduled docsgen job and a sqlite file, which is a small enough footprint to run on modest hardware.

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

Treat the free-tier numbers as volatile. The project page states a token allotment and a free server tier, and those terms should be read there on the day you adopt them rather than from any article, including this one. The engineering point stands either way: a cache keyed on inputs makes an unreliable tier usable, because a failed run costs you a retry and not a corrupted doc tree.

6. What the model may draft, and what a human must own

The cache answers "is this reproducible." It does not answer "is this right," and the split below is the difference between a pipeline and a liability.

Artifact Model may draft Human must own
Prerequisite lists Yes, from declared sources Final omission check
Parameter descriptions Yes, from code and docstrings Whether the parameter is public
Worked examples Narration only The runnable file the example points to
"Supported / not supported" claims No Yes, with a primary source
Deprecation dates and version ranges No Yes
Security and permission behavior No Yes
Section owner and prompt version No Yes, in contract.yml

Two rules make this table enforceable. First, every row marked "No" gets mode: human-only in the contract, so the pipeline physically cannot write it. Second, every accepted claim gets a source path in sources:, because a claim with no file behind it is exactly the thing a model will happily invent and a reviewer will happily approve.

7. Limitations and who should skip this

The approach assumes your source of truth lives in the repository. If prerequisites are configured in a vendor console, the fingerprint cannot see them, and caching will happily freeze a stale draft forever. It also assumes someone reads diffs: an idempotent pipeline that nobody reviews produces stable, confidently wrong documentation on a schedule.

Skip this if you have no reviewer for docs pull requests, if the page is a security or compliance statement, or if your team cannot maintain a contract.yml. For those cases a single human-written page per quarter beats a nightly job nobody owns. Small teams with one reviewer and a handful of generated pages are the sweet spot; large doc sites will want the same fingerprint idea wired into their existing build rather than a parallel script.

Start with three sections, run the cold-cache audit on each, and demote anything that changes a number. If you want to try it end to end without a billing decision, the free model access and free server option are enough to run the cron job while you evaluate it — bring your own contract.yml and leave the stability gate switched on.

📰 Read the original article on Dev.to AI

Originally published by Dev.to AI. Aggregated on AIWithGhost for educational purposes — full credit and traffic to the original publisher.