Model-drafted documentation is useful only when every generated claim maps to a file the repository already owns. Sentences without a source become unofficial contracts, and those contracts are expensive to walk back after publication. The practical split is simple: models may restate checked-in facts, while humans own promises, versions, and unsupported advice. This article describes a cite-or-drop pipeline that enforces that split before a draft reaches a pull request.
Restatement is cheap; a promise is not
Classifying a heading as draftable still leaves the model free to invent numbers, support windows, and compatibility language. A parameter table copied from OpenAPI is a restatement; a sentence that calls an endpoint production-stable is a promise. Reviewers who only skim markdown diffs tend to miss that second class because it reads like ordinary explanatory prose. Binding each sentence to a path, a symbol, or an ADR identifier makes the missing source visible in continuous integration.
The expensive failure is not purple prose, but a getting-started page that upgrades a beta flag into a supported contract. Support teams then inherit a sentence nobody signed, and revert cost lands after customers have already quoted the line. A source-bound pipeline treats that sentence as a build failure rather than a style comment on the pull request. Heading labels remain useful for navigation, yet they cannot see a promise that sits inside an otherwise draftable section.
A decision table for draft rights
Use this table as a checked-in policy, not as a vibe check that reviewers apply after the model has already written. Rows name documentation surfaces, and columns separate restatable facts from commitments that require a human owner. If a proposed sentence cannot name a row and a column, it does not belong in a model-touched markdown file.
| Surface | Model may draft | Human must own | Required source |
|---|---|---|---|
| Getting-started commands | Copy from Makefile targets or CLI help text | Claims that the flow is production ready |
Makefile, command entrypoints |
| API parameter tables | Names, types, enums, required flags | Stability language, SLA, will-not-change | OpenAPI or protobuf |
| Changelog restatements | Bullet text already present in CHANGELOG | Safe-to-skip, no-downtime, no-migration |
CHANGELOG.md plus a git tag |
| Error catalogs | Code, message template, HTTP status | Customer-facing severity and workaround | Source error types |
| Architecture narrative | Module list from lockfiles or CODEOWNERS | Why the design is correct | ADRs only; otherwise human |
| Security and privacy | Links to existing policy files | Threat claims and encryption guarantees | Human only |
| Pricing and support windows | None | Entire section | Human only |
| Version and compatibility |
git describe output and module path |
Supported-until dates and backport policy | Human plus release notes |
The table is the working contract for draft jobs, extractors, and tests that later drop unbound sentences. Expand rows only when a new source artifact exists in the repository and a human agrees it may be restated. Do not add a row for conceptual essays that have no cite root, because those pages are human-owned by default.
Four-stage pipeline
The pipeline has four stages that run in order and fail closed when a claim cannot be bound. First, inventory the source artifacts that are allowed to justify prose in any model-touched file. Second, extract candidate claims from a model draft as structured records instead of scoring raw markdown. Third, bind each record to a cite root; fourth, reject the pull request when unbound claims remain.
The following sections treat the implementation as a proposed workflow rather than a report of production results. Commands, fixtures, and tests are labeled as unexecuted examples and need local adaptation before enforcement. Keep generated pages under a dedicated tree so human-owned documents never share a glob with model output.
Step 1: Inventory the files a model is allowed to cite
Create a checked-in bind map so citation roots cannot drift from prompt to prompt across draft jobs. Paths listed in that map are the only files a draft job may read when it composes restatements. Human-only globs are exclusions, not suggestions, and a write into those paths should fail the job.
# docs/bind-map.yml (proposed, unexecuted)
version: 1
cite_roots:
- path: openapi/openapi.yaml
kinds: [api_table, error_status]
- path: CHANGELOG.md
kinds: [changelog]
- path: Makefile
kinds: [command]
- path: adr/
kinds: [architecture]
human_only_globs:
- docs/support/**
- docs/security/**
- docs/pricing.md
claim_patterns:
- name: numeric_guarantee
regex: '\b(\d+)\s*(ms|seconds|nines|years|months)\b'
default_owner: human
- name: modal_promise
regex: '\b(always|never|guaranteed|production-ready|SLA)\b'
default_owner: human
Keep the bind map small so it cannot become a hidden dump of the entire repository for the prompt. A large cite-root list recreates unbounded generation under a different filename and defeats the ownership split. Review changes to the map with the same seriousness as CODEOWNERS, because the map grants the model read rights.
Step 2: Normalize a draft into claim records
Split each draft into claim records so continuous integration can fail on one sentence without debating the whole page. The schema below is intentionally boring so a later human edit can still pass the same structural tests. Owner is either model or human; unbound stays true until a deterministic join fills source_path.
# scripts/claim_schema.py (proposed, unexecuted)
from __future__ import annotations
from pydantic import BaseModel, Field
class Claim(BaseModel):
heading_path: str
text: str
kind: str
source_path: str | None = None
source_anchor: str | None = None
owner: str = Field(pattern="^(model|human)$")
unbound: bool = True
A first extractor can start with sentence splits plus the regexes already declared in the bind map. Richer extractors can wait until the gate is boring, because the ownership rule matters more than linguistic sophistication. Numeric guarantees and modal promises default to human ownership even when they appear under a draftable heading.
# scripts/extract_claims.py (proposed, unexecuted)
import re
from pathlib import Path
from claim_schema import Claim
SENTENCE = re.compile(r"(?<=[.!?])\s+")
def extract_claims(markdown: str, heading_path: str, patterns: list[dict]) -> list[Claim]:
claims: list[Claim] = []
for text in SENTENCE.split(markdown.strip()):
text = text.strip()
if not text:
continue
owner = "model"
kind = "prose"
for pattern in patterns:
if re.search(pattern["regex"], text, re.I):
owner = pattern["default_owner"]
kind = pattern["name"]
break
claims.append(
Claim(
heading_path=heading_path,
text=text,
kind=kind,
owner=owner,
unbound=True,
)
)
return claims
Step 3: Bind claims to cite roots, then drop the rest
Binding is a deterministic join against cite roots, not a second model call that re-explains the same paragraph. For API tables, join backtick-wrapped names to OpenAPI parameter names and require a full subset match. For changelog restatements, require a verbatim match against a bullet so paraphrase cannot invent downtime language. For commands, require the exact Make target or CLI path to exist before the sentence may remain.
# scripts/bind_claims.py (proposed, unexecuted)
from pathlib import Path
import yaml
def load_openapi_names(path: Path) -> set[str]:
spec = yaml.safe_load(path.read_text())
names: set[str] = set()
for _route, methods in (spec.get("paths") or {}).items():
for _method, op in (methods or {}).items():
if not isinstance(op, dict):
continue
for param in op.get("parameters") or []:
if "name" in param:
names.add(param["name"])
return names
def bind(claims, repo: Path):
openapi = repo / "openapi" / "openapi.yaml"
names = load_openapi_names(openapi) if openapi.exists() else set()
changelog_path = repo / "CHANGELOG.md"
changelog = changelog_path.read_text() if changelog_path.exists() else ""
makefile = (repo / "Makefile").read_text() if (repo / "Makefile").exists() else ""
for claim in claims:
if claim.kind == "api_table":
tokens = {t.strip("`") for t in claim.text.split() if t.startswith("`")}
if tokens and tokens <= names:
claim.source_path = "openapi/openapi.yaml"
claim.unbound = False
elif claim.kind == "changelog" and claim.text.lstrip("- ") in changelog:
claim.source_path = "CHANGELOG.md"
claim.unbound = False
elif claim.kind == "command":
targets = {t.strip("`") for t in claim.text.split() if t.startswith("`")}
if targets and all(t + ":" in makefile or t in makefile for t in targets):
claim.source_path = "Makefile"
claim.unbound = False
elif claim.owner == "human":
claim.unbound = True
return claims
A claim with owner human is not a passing result; it is a signal that the sentence must move. Move that sentence into a human-owned file with a reviewer, or delete it from the model draft entirely. Leaving it in docs/generated with unbound: true is the case the next test is designed to reject.
Step 4: Fail CI when a model file still contains unbound claims
Store accepted claims in a sidecar JSON file so later diffs show new promises as new records, not buried prose. The test below fails closed when a model-touched page contains unbound claims or is missing its sidecar. Run the gate on every pull request that touches docs/generated, the bind map, or the extractor scripts.
# tests/test_doc_bind.py (proposed, unexecuted)
import json
from pathlib import Path
def test_model_pages_have_no_unbound_claims():
failures = []
pages = list(Path(".").glob("docs/generated/**/*.md"))
assert pages, "no generated pages found; check the glob before enforcing"
for md in pages:
sidecar = md.with_suffix(".claims.json")
assert sidecar.exists(), f"missing claim sidecar for {md}"
records = json.loads(sidecar.read_text())
for row in records:
if row.get("unbound"):
failures.append(f"{md}: {row['text']}")
assert failures == [], "Unbound claims in model drafts:\n" + "\n".join(failures)
# Makefile (proposed, unexecuted)
.PHONY: docs-bind docs-draft docs-openapi-fixture
docs-bind:
python scripts/extract_claims.py docs/generated
python scripts/bind_claims.py docs/bind-map.yml
pytest tests/test_doc_bind.py -q
docs-draft:
@echo "Run the draft job only after docs-bind is green on the previous tree."
@echo "Label the output as a proposal and keep it under docs/generated/."
docs-openapi-fixture:
@test -f openapi/openapi.yaml || (echo "missing openapi/openapi.yaml" && exit 1)
Example sidecar rows make the policy concrete for reviewers who do not want to read the extractor first. The first row is a restated parameter and may ship; the second row is a promise and must be dropped. Keep both rows in code review discussions so the failure reads as a missing source, not as a style nit.
[
{
"heading_path": "reference/widgets#create",
"text": "`dry_run` is a boolean query parameter.",
"kind": "api_table",
"source_path": "openapi/openapi.yaml",
"owner": "model",
"unbound": false
},
{
"heading_path": "reference/widgets#create",
"text": "The endpoint is always production-ready and guaranteed within 50 ms.",
"kind": "modal_promise",
"source_path": null,
"owner": "human",
"unbound": true
}
]
A minimal OpenAPI fragment shows what a legal restatement is allowed to see. Anything beyond names, types, and required flags still belongs to a human-owned compatibility page.
# openapi/openapi.yaml (fixture for the proposed join)
paths:
/widgets:
post:
parameters:
- name: dry_run
in: query
required: false
schema:
type: boolean
Where a free draft host participates
The draft step is the only stage that needs a model, and it should not share credentials with production deploy jobs. Teams that already use MonkeyCode can run that isolated draft step on its free model access and free server option. Disclosure: This article was prepared as part of MonkeyCode's product outreach, and that relationship should be read before any product detail. Treat that access only as an execution venue for drafts, not as a quota, hardware, quality, or permanence claim.
The binder, the OpenAPI join, and the pytest gate should stay in ordinary CI because they are deterministic and cheaper to audit. If the draft host is unavailable, the repository still has a bind map, claim records, and a failing test for new prose. Generation can pause without changing ownership rules, which is the point of keeping the gate independent from the model.
Prompt the draft job with the bind map rather than an open instruction to write documentation for the entire repository. A constrained prompt reduces unbound claims before continuous integration has to drop them in a later failing test. The template below is a proposal and should be versioned next to the bind map so prompt drift is reviewable.
# prompts/source-bound-draft.txt (proposed, unexecuted)
Read docs/bind-map.yml and only the cite_roots listed there.
Draft markdown under docs/generated/ for API tables and changelog restatements.
Do not write files matching human_only_globs.
Every sentence that contains a number, a modal promise, or a stability claim
must be omitted unless it is a verbatim restatement of a cite root.
Leave TODO(human) on any heading that needs a promise, a version policy,
or a workaround that is not in source.
Local dry-run order stays boring on purpose. Inventory, extract, bind, and test should succeed on an empty docs/generated tree before any model write is enabled.
# proposed local sequence (unexecuted)
git ls-files openapi/openapi.yaml CHANGELOG.md Makefile docs/bind-map.yml
python scripts/extract_claims.py docs/generated
python scripts/bind_claims.py docs/bind-map.yml
pytest tests/test_doc_bind.py -q
Limitations
Sentence splitting will mishandle abbreviations, markdown tables, and fenced command blocks until the extractor is tuned on real pages. Near-verbatim changelog matching will reject useful paraphrases, which is intentional because paraphrase is where invented downtime claims appear. OpenAPI joins will not catch semantic lies that use the right parameter names inside the wrong operational sentence. The pipeline does not measure readability, translation quality, or whether human-owned pages are factually correct.
Sidecar files and the bind map can rot if nobody owns docs/bind-map.yml the way CODEOWNERS owns review paths. If the map lists generated files as cite roots, the model will cite itself and the gate becomes empty theater. Re-run the inventory whenever OpenAPI paths move, or the join will fail closed on restatements that are still legitimate.
Who should not use this approach
Do not adopt this pipeline if the project has no OpenAPI, changelog, Makefile, or other checked-in source that could justify restatements. Do not use it to auto-publish customer support pages, security advisories, pricing tables, or legal text, even when a draft looks fluent. Do not wire the draft host into production secrets, and do not treat a green bind test as a substitute for review of human-owned sections. Teams that need marketing narrative rather than reference restatement will find cite-or-drop too strict and should keep generation out of the tree.
If the only goal is a faster first draft of a conceptual essay, skip the model write path and keep a human outline. The pipeline is for repositories that already have specs and need a mechanical stop against unofficial promises. Restate what the files already say, and leave every unsupported guarantee in a human-owned page with a named reviewer.
Disclosure: This article was prepared as part of MonkeyCode's product outreach.
Top comments (1)
The restatement-versus-promise line is a sharper version of something I have only ever seen enforced by having the right reviewer happen to read the page. It also explains why this class of error survives review: a promise is grammatically indistinguishable from the sentences around it, so a diff full of accurate parameter tables reads as accurate throughout. Making the missing citation a CI failure moves the check from attention to mechanism, which is the only thing that scales. The failure mode worth guarding next is the citation that resolves but no longer says what it said - a sentence bound to a path stays green while the file changes underneath it, so the binding needs to pin a symbol or a content hash rather than a location. Otherwise you get the RAG problem in documentation form: a real citation supporting a claim that is quietly out of date.