Source-Bound Docs: Models Restate Files, Humans Own Promises
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 fac

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. 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. 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. 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. 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. 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 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. 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 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 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. 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.
Key Takeaways
- โขModel-drafted documentation is useful only when every generated claim maps to a file the repository already owns
- โขThis story was reported by Dev.to, covering developments in the dev space.
- โขAI advancements continue to reshape industries โ read the full article on Dev.to for complete coverage.
๐ Continue reading the full article:
Read Full Article on Dev.to โShare this article



