Most generated documentation does not fail because a sentence is wrong; it fails because the sentence that would have prevented a bad decision was never written. Retry limits, unsupported inputs, deprecation paths, quota ceilings, and "do not use this for X" all describe absence, and absence has no line of code you can point a citation at. That asymmetry is the real ownership boundary in model-drafted docs: the model may draft what the repository can prove, and a human must own everything the repository can only omit.
This article describes a small, reproducible workflow for that boundary. It splits every page into human-owned fact blocks and model-owned prose, renders the absence claims verbatim, and fails the build when the draft asserts a limitation it cannot source. It complements citation and fact-fidelity checks rather than replacing them, because those checks pass silently on a page that never mentions its limits.
1. Why "facts vs. prose" is the wrong split
A fact table expresses presence well, since you can usually resolve a value, a signature, or an example to a file and a line. A fact table expresses absence badly, because nothing in the source tree states that the client refuses non-idempotent retries or that the free tier excludes background jobs.
When a model drafts a page from code, it reproduces what the code contains and quietly drops what the code lacks. A reviewer skimming a fluent page rarely notices a missing limitation paragraph, and that omission is exactly the content that costs a reader an afternoon. So the split has to be drawn on presence versus absence, not on nouns versus verbs.
2. Classify each block by who can prove it
Before drafting anything, assign every block of the page to a draft owner. The table below is the one I would apply to an HTTP client library page, and the middle column is the decision you are actually making.
| Doc block | Draft owner | Enforcing gate |
|---|---|---|
| What the function does, signature, return shape | model, cited | fact-fidelity check you already run |
| Ordered usage steps and code examples | model | executable-example gate |
| Prerequisites the caller must supply | human | required-key gate |
| Hard limits and unsupported inputs | human | required-key gate, rendered verbatim |
| "Do not use this for..." guidance | human | required-key gate, rendered verbatim |
| Cost, quota, free-tier and expiration claims | human | tripwire lint, no exceptions |
| Deprecation, migration, rollback steps | human | required-key gate |
Anything that quantifies a resource or a price belongs in the human column even when a config file exists. Configuration is a statement about the default deployment, not a promise about the reader's account, and a model that reads max_retries: 3 will happily publish it as a product limit.
3. Put the absence claims in a human-owned file
The human artifact is one small YAML file per page, committed under docs/_facts/, with a required block for each negative-space category and no prose styling rules attached. The values must be complete sentences, because they will be rendered into the page without editing.
# docs/_facts/http-retry.yaml prereqs: - "Your HTTP library must expose the response status and the Retry-After header to the caller." limits: - "The client retries a request at most three times, and it does not retry non-idempotent requests." - "Retry delays follow the server hint when present; otherwise the client backs off exponentially." not_for: - "Do not use this client for long-polling connections, because retries will duplicate subscriptions." One rule makes this file safe: the text is rendered verbatim, never paraphrased by the model and never summarized by a downstream step. If a sentence here is wrong, the review happens on a ten-line diff instead of a two-hundred-line page rewrite.
4. Let the model draft prose with fact markers only
The drafting step writes connective text, headings, and the surrounding explanation, and it may not write any sentence containing a negation, a limit, a cost, or a prescription. When such a sentence is needed, the draft places a marker instead, and the render step substitutes the human sentence.
<!-- docs/_draft/http-retry.md --> The client wraps your request function and calls it again when the transport fails. ## Limits - [[fact:http-retry.limits.0]] - [[fact:http-retry.limits.1]] ## Who should not use this client [[fact:http-retry.not_for.0]] ## Before you start [[fact:http-retry.prereqs.0]] The drafting instruction is three lines and fits in a system prompt: write only descriptive prose, never assert a limit or a cost, and place a [[fact:...]] marker wherever a claim about absence belongs. This is the step where a hosted drafting layer helps, and I run it through MonkeyCode's free model access on the free server option because drafting connective prose is low-stakes work that does not need a paid key. Disclosure: This article was prepared as part of MonkeyCode's product outreach. Availability and limits of that access are set by the operator, so confirm the current terms yourself before you build a pipeline on it.
5. Gate the draft with a negative-space lint
The checker below enforces two rules: every required negative-space block must exist and be cited at least once, and every sentence containing a tripwire word must carry a source marker. It needs only pyyaml, and it is deliberately small enough that a reviewer can read all of it.
#!/usr/bin/env python3 """Fail a doc draft when the model writes absence claims it cannot source.""" from __future__ import annotations import pathlib import re import sys import yaml ROOT = pathlib.Path("docs") FACTS = ROOT / "_facts" DRAFT = ROOT / "_draft" REQUIRED = ("prereqs", "limits", "not_for") TRIPWIRE = re.compile( r"\b(not|never|cannot|can't|unsupported|deprecated|only|must|requires?|" r"limits?|quotas?|costs?|pricing|free|expires?)\b", re.IGNORECASE, ) MARKER = re.compile(r"\[\[fact:([A-Za-z0-9_.\-]+)\]\]") SPLIT = re.compile(r"(?<=[.!?])\s+|\n{2,}") def prose_only(text: str) -> str: text = re.sub(r"``` .*? ```", "", text, flags=re.S) return re.sub(r"^#{1,6}.*$", "", text, flags=re.M) def known_ids(page: str, data: dict) -> set[str]: ids: set[str] = set() for key, value in data.items(): if isinstance(value, list): ids |= {f"{page}.{key}.{i}" for i in range(len(value))} else: ids.add(f"{page}.{key}") return ids def check(page: str) -> list[str]: errors: list[str] = [] data = yaml.safe_load((FACTS / f"{page}.yaml").read_text()) ids = known_ids(page, data) body = prose_only((DRAFT / f"{page}.md").read_text()) used = MARKER.findall(body) for key in REQUIRED: if key not in data: errors.append(f"{page}: no human-owned '{key}' block") elif not any(ref.startswith(f"{page}.{key}.") for ref in used): errors.append(f"{page}: '{key}' exists but the draft never cites it") for chunk in SPLIT.split(body): sentence = " ".join(chunk.split()) if not sentence or not TRIPWIRE.search(sentence): continue refs = MARKER.findall(sentence) if not refs: errors.append(f"{page}: unsourced claim -> {sentence[:100]}") for ref in refs: if ref not in ids: errors.append(f"{page}: dangling marker [[fact:{ref}]]") return errors def main() -> int: pages = sorted(p.stem for p in DRAFT.glob("*.md")) errors = [error for page in pages for error in check(page)] for error in errors: print(error) print(f"{len(errors)} error(s) across {len(pages)} page(s)") return 1 if errors else 0 if __name__ == "__main__": sys.exit(main()) With the fixture above, one draft sentence fails before the first run is complete, because it paraphrases a limitation instead of citing it. The expected output is a single line naming the offending sentence, then the summary count; fix it by deleting the paraphrase and letting the rendered fact carry the claim.
$ python tools/negative_space_check.py docs/_draft/http-retry.md: unsourced claim -> It does not retry every request, so read the limits below before you enable it. 1 error(s) across 1 page(s) The render step is fifteen lines and substitutes each marker with the human sentence, keeping a generated-file header so nobody hand-edits the output.
def render(page: str) -> None: data = yaml.safe_load((FACTS / f"{page}.yaml").read_text()) def sub(match: re.Match) -> str: _, key, idx = match.group(1).rsplit(".", 2) value = data[key] return value[int(idx)] if isinstance(value, list) else value text = MARKER.sub(sub, (DRAFT / f"{page}.md").read_text()) header = "<!-- generated from docs/_draft; edit facts, not this file -->\n\n" (ROOT / f"{page}.md").write_text(header + text) 6. Guard the folder boundary in CI
The lint protects the content, and a path guard protects the ownership model itself, because a drafting job that edits docs/_facts/ can quietly promote a guess into a human-owned fact. Run this on the model's branch before merge, so the only legal write target is the draft folder.
outside=$(git diff --name-only origin/main...HEAD -- docs | grep -Ev '^docs/_draft/' || true) if [ -n "$outside" ]; then echo "blocked: draft job wrote outside docs/_draft" echo "$outside" exit 1 fi 7. Record three numbers per page
Do not take my word for whether this helps; collect the baseline yourself on five pages before you adopt it. First, count tripwire sentences without a source marker, which should drop to zero and stay there. Second, count required negative-space blocks that exist in _facts but are never cited, which catches removal drift after refactors. Third, record the review diff size for the facts file, because a reviewer who reads ten lines of YAML is doing a different job than one auditing a regenerated page. Track these per release and watch the second number, since unused facts mean a page silently lost its warnings.
Limitations and who should not use this
The tripwire regex is crude, and words like "only" or "must" appear in harmless descriptive prose often enough that you will maintain a small allowlist. Sentence splitting also breaks on tables, nested lists, and inline code, so treat the checker as a linter with false positives rather than a correctness proof. Pages that genuinely have no negative space, such as a pure tutorial with no configuration surface, gain nothing from the required-key gate and should opt out of it explicitly.
Skip this workflow if nobody on the team can honestly author the absence claims, because the gate will then produce empty YAML that passes and teaches reviewers to trust an unchecked section. Skip it as well for single-author repositories where the same person drafts and reviews every page, since the ceremony costs more than the review it replaces. For everyone else, the useful question is not whether the model can write the page, but which sentences a model should never be allowed to invent.
If you want to try the drafting step without committing a paid key, the free model access and free server option on MonkeyCode are enough to run step four against a single page.
