Documentation rots in two different ways at the same time, and treating them as one problem is why doc review queues stall. Prose that a human owns drifts slowly, because nobody rewrites a policy paragraph when the flag underneath it changes. Prose that a model can derive from a schema drifts instantly, because the source changed an hour ago and the paragraph still lists the old fields. A single "regenerate the docs" job cannot serve both, because one case needs a writer and the other needs a scheduler.
The workflow below separates them mechanically. Every documentation block carries an owner and a pinned hash of the source it depends on, so staleness becomes a comparison of two strings instead of a reviewer's memory. A checker routes each stale block into exactly one of three actions and exposes the routing as a process exit code that CI can gate on. The model drafts into a candidate directory; the hash pin plus a signoff ledger decide what becomes true in the tracked document.
1. Three owners, three actions
The whole design fits in a routing table, which is the part worth arguing about in review before any code exists.
| Owner | Who writes the body | Action when the source moves | Merge path |
|---|---|---|---|
human |
A person, from incident memory or a recorded decision |
escalate (exit bit 4) |
A human edits the block and re-pins it with --signoff
|
model |
Derivable from a schema, spec, or generated artifact |
refresh (exit bit 2) |
A candidate file is queued; a human applies it after reading the diff |
mixed |
A model drafts a summary of a human-owned decision |
propose (exit bit 2) |
Candidate plus an explicit diff; the human resolves the wording |
The important asymmetry is that model blocks are the only ones whose bodies may be produced without a person in the loop. The human rows above are not a slower version of the same thing; they are the rows where the model has no access to the reason the sentence exists. Marking semantics, deprecation notices, and security caveats belong in human blocks almost every time, because the source file records what the code does and never records why a tradeoff was accepted.
The markup is deliberately boring, so it survives Markdown renderers and stays greppable in a diff.
# Payments API
<!-- doc:block id=response-shape owner=model src=openapi.yaml hash=1f3a9c04b7d2 -->
`POST /charges` returns `id`, `amount`, `currency`, and `status`.
<!-- /doc:block -->
<!-- doc:block id=retry-semantics owner=human -->
Retries are permitted only with an idempotency key; the reasoning is in RFC-14.
<!-- /doc:block -->
2. Parse the blocks and hash the sources
The checker below uses only the Python standard library, makes no network calls, and prints one line of summary. Splitting it across two listings here is a reading convenience; it is a single file named docfresh.py.
#!/usr/bin/env python3
"""docfresh.py - staleness triage for provenance-tagged documentation blocks."""
from __future__ import annotations
import argparse, hashlib, json, re, sys
from dataclasses import dataclass
from datetime import datetime, timezone
from pathlib import Path
OPEN_RE = re.compile(r"^<!--\s*doc:block\b(?P<attrs>.*?)-->$")
ATTR_RE = re.compile(r"(?P<key>[a-z_]+)=(?P<value>\S+)")
CLOSE = "<!-- /doc:block -->"
POLICY = {"human": "escalate", "model": "refresh", "mixed": "propose"}
EXIT_BITS = {"escalate": 4, "refresh": 2, "propose": 2}
FAULTS = {"source-missing", "unknown-owner", "unpinned"}
PROMPT = ("Rewrite only the body of block id={id} in {doc}. Use facts from {src} "
"and nothing else. Keep headings and code fences. Reply with the body only.")
@dataclass
class Block:
block_id: str
owner: str
src: str | None
pin: str | None
line: int
text: str = ""
def parse_blocks(path: Path) -> list[Block]:
blocks, current = [], None
for lineno, raw in enumerate(path.read_text(encoding="utf-8").splitlines(), 1):
line = raw.strip()
match = OPEN_RE.match(line)
if match:
if current is not None:
raise ValueError(f"{path}:{lineno}: nested doc:block")
attrs = dict(ATTR_RE.findall(match.group("attrs")))
if {"id", "owner"} - attrs.keys():
raise ValueError(f"{path}:{lineno}: id and owner are required")
current = {"attrs": attrs, "line": lineno, "text": []}
continue
if line == CLOSE:
if current is None:
raise ValueError(f"{path}:{lineno}: close tag without open")
attrs = current["attrs"]
blocks.append(Block(attrs["id"], attrs["owner"], attrs.get("src"),
attrs.get("hash"), current["line"],
"\n".join(current["text"])))
current = None
continue
if current is not None:
current["text"].append(raw)
if current is not None:
raise ValueError(f"{path}: unclosed doc:block at line {current['line']}")
return blocks
def file_hash(path: Path) -> str:
digest = hashlib.sha256()
with path.open("rb") as handle:
for chunk in iter(lambda: handle.read(65536), b""):
digest.update(chunk)
return digest.hexdigest()[:12]
3. Classify the blocks and queue jobs instead of edits
The classifier returns a row per block, and the command writes two artifacts: triage.json for humans and one job file per stale machine-ownable block. Note that the checker never writes to the tracked document, which is the property that makes it safe to run on every commit.
def triage_one(root: Path, doc: Path, block: Block) -> dict:
row = {"doc": str(doc), "id": block.block_id, "owner": block.owner,
"src": block.src, "action": "ok", "reason": "fresh"}
action = POLICY.get(block.owner)
if action is None:
row.update(action="escalate", reason="unknown-owner")
elif block.src is None:
if block.owner == "human":
row["reason"] = "human-owned"
else:
row.update(action="escalate", reason="no-source")
elif not (root / block.src).exists():
row.update(action="escalate", reason="source-missing")
elif not block.pin:
row.update(action=action, reason="unpinned")
elif file_hash(root / block.src) != block.pin:
row.update(action=action, reason="source-changed")
return row
def cmd_check(args) -> int:
rows, jobs, code = [], [], 0
for doc in args.docs:
for block in parse_blocks(Path(doc)):
row = triage_one(args.root, Path(doc), block)
if row["reason"] in FAULTS:
code |= 8
code |= EXIT_BITS.get(row["action"], 0)
if row["action"] in {"refresh", "propose"}:
jobs.append({"block": row["id"], "doc": row["doc"], "src": row["src"],
"reason": row["reason"],
"prompt": PROMPT.format(id=row["id"], doc=row["doc"], src=row["src"]),
"current": block.text,
"write_to": "candidate, never the tracked doc"})
rows.append(row)
out = args.root / ".docfresh"
(out / "jobs").mkdir(parents=True, exist_ok=True)
(out / "triage.json").write_text(json.dumps(rows, indent=2) + "\n", encoding="utf-8")
for job in jobs:
(out / "jobs" / f"{job['block']}.json").write_text(
json.dumps(job, indent=2) + "\n", encoding="utf-8")
print(f"{len(rows)} blocks, {len(jobs)} jobs, exit {code}")
return code
The exit code is a bitmask, so a pipeline can block on one bit and merely warn on another. Bit 2 means machine-ownable work is waiting, bit 4 means a human-owned block is stale, and bit 8 means the metadata itself is broken. An unpinned block counts as a fault rather than a pass, so the first run after adding a block fails loudly and forces the author to pin it once.
4. Run the drafting step on a free tier
The job files are plain JSON, which keeps the drafting step swappable and the checker testable offline. Each job carries the source path, the current body, and a prompt that forbids facts from outside the named source. MonkeyCode is one place to execute that step: its free model access and its free server option are the reason this loop can run on a schedule rather than on demand. Disclosure: This article was prepared as part of MonkeyCode's product outreach.
A job looks like this, trimmed to the fields that matter:
{
"block": "response-shape",
"doc": "docs/payments.md",
"src": "openapi.yaml",
"reason": "source-changed",
"prompt": "Rewrite only the body of block id=response-shape in docs/payments.md. Use facts from openapi.yaml and nothing else. Keep headings and code fences. Reply with the body only.",
"write_to": "candidate, never the tracked doc"
}
The runner reads the source file, sends the prompt, and writes the reply to .docfresh/candidates/response-shape.md. Nothing in that path touches docs/payments.md, so a wrong or hallucinated candidate costs a reviewer thirty seconds instead of a bad commit. For mixed blocks the runner should also emit a unified diff against the pinned body, because the review question there is whether the drafted summary still matches a decision the model never saw.
5. Pin a block only after a human accepts it
Re-pinning is the moment a claim becomes owned, so it needs a speed bump and an audit trail. The pin subcommand refuses to run unless --ack repeats the block id, and it refuses human-owned blocks without a named signoff. Accepted pins append to .docfresh/ledger.jsonl, which is what you read when someone asks who approved a sentence.
def cmd_pin(args) -> int:
if args.ack != args.id:
print("refusing: --ack must repeat the block id", file=sys.stderr)
return 2
blocks = {b.block_id: b for b in parse_blocks(args.doc)}
block = blocks.get(args.id)
if block is None or not block.src:
print(f"refusing: block {args.id} has no pinnable src", file=sys.stderr)
return 2
if block.owner == "human" and not args.signoff:
print("refusing: human-owned blocks need --signoff <name>", file=sys.stderr)
return 4
new_pin = file_hash(args.root / block.src)
lines = args.doc.read_text(encoding="utf-8").splitlines(keepends=True)
idx = block.line - 1
line = lines[idx]
lines[idx] = (re.sub(r"hash=\S+", f"hash={new_pin}", line, count=1)
if "hash=" in line
else re.sub(r"\s*-->", f" hash={new_pin} -->", line, count=1))
args.doc.write_text("".join(lines), encoding="utf-8")
ledger = args.root / ".docfresh" / "ledger.jsonl"
ledger.parent.mkdir(parents=True, exist_ok=True)
with ledger.open("a", encoding="utf-8") as handle:
handle.write(json.dumps({"block": args.id, "owner": block.owner, "pin": new_pin,
"signoff": args.signoff or args.operator,
"at": datetime.now(timezone.utc).isoformat(timespec="seconds")}) + "\n")
print(f"pinned {args.id} -> {new_pin}")
return 0
A ledger line is short enough to read in a pull request, and the timestamp makes a stale approval visible during an audit.
{"block": "retry-semantics", "owner": "human", "pin": "9c2ab7f10d43", "signoff": "avery.lin", "at": "2026-10-16T09:14:02+00:00"}
6. A fixture plan you can write in one sitting
The fixtures below are a plan rather than a measured benchmark, but every expected exit code follows directly from the bit table above. Keep each fixture in its own directory with a copy of the checker, so --root stays pointing at the fixture root.
| Fixture | Setup | Expected exit | Expected side effect |
|---|---|---|---|
fresh |
both pins match their sources | 0 | no job files written |
model-drift |
edit openapi.yaml after pinning |
2 | one job for response-shape, doc unchanged |
human-drift |
edit the source behind a human block |
4 | triage row escalate, doc unchanged |
unknown-owner |
set owner=bot on one block |
12 | triage row escalate, no job |
unpinned-new |
add a model block with no hash=
|
10 | job queued and fault bit set |
unclosed |
delete a close tag | 1 | parser raises unclosed doc:block
|
python3 docfresh.py check --root . docs/payments.md
python3 docfresh.py pin --root . --id response-shape --ack response-shape docs/payments.md
python3 docfresh.py pin --root . --id retry-semantics --ack retry-semantics \
--signoff "avery.lin" docs/payments.md
for f in fixtures/*/; do (cd "$f" && python3 docfresh.py check --root . docs/*.md); echo "$f -> $?"; done
7. Limitations and failure modes
The hash tells you that a source changed, not that the change matters to a given sentence, so you will see refreshes for comment-only edits and renames. The checker verifies routing, not truth: a candidate can cite the right file and still misread it, which is exactly why the merge path stays manual. Hash churn is real for sources touched by every commit, and pin fatigue will push a team to pin lazily unless the CI gate stays at bit 4 only. Cross-document dependencies are also out of scope, because a block that depends on two sources needs a list of hashes rather than one string.
8. Who should not use this
Skip this approach if your docs have no per-block source of truth, because pinning prose to an unrelated file creates false confidence rather than coverage. Skip it for narrative documents such as architecture essays, where the value is the reasoning and every block is effectively human. Skip it as well if the goal is unattended regeneration of published text, since this design deliberately keeps a person between a candidate and a claim. Teams that cannot maintain a fixture directory will get more value from a plain link checker first.
Start with one documentation file, tag three blocks, and run the fixture plan before wiring the checker into CI. If the exit-code contract holds on those six cases, extend it one document at a time and keep the human set generous for the first month.
Top comments (0)