Generated documentation fails when a model writes claims that no repository artifact can later prove true. Draft rights belong at section granularity, because a single page often mixes recoverable facts with human judgment. Score every section on recoverability and judgment density, and then refuse generation below a fixed threshold. Humans retain policy language, product intent, and conversation-only facts, while models may draft only source-backed material.
Page flags hide mixed ownership
Many teams still mark an entire Markdown file as generated or human-owned, which collapses unlike sections into one permission. A reference page can contain a CLI table that tests already prove, plus a rationale paragraph that only product counsel should write. When a model receives the whole page, it tends to complete the human-owned paragraph with plausible language that nobody can later verify. The failure is not fluency; the real failure is missing recoverability for a subset of claims.
Ownership that lives only in a pull-request checklist arrives after the draft already exists, which is the expensive moment to discover a boundary error. A numeric gate before prompt assembly is cheaper, because blocked sections never enter the model context at all. The rest of this article treats that gate as a small, testable contract checked in beside the docs tree. It is a proposal with runnable examples, not a report of production incident rates.
Two integers beat a boolean
Treat each section as a pair of integers from zero to three, not as a generated-or-not flag. Recoverability measures whether a later reader could rebuild the claims from files that already live in the repository. Judgment density measures how much of the section encodes intent, risk acceptance, or policy that no test suite can state. Require recoverability of at least two and judgment of at most one before any model may draft that section.
Use the table below as a starting matrix, then tighten rows when a section type repeatedly fails review. The labels are operational, not moral: a low recoverability score means the repository cannot prove the claims, not that the prose is unimportant. High judgment density means a human must own the wording even when some facts are nearby in code. Mixed scores should fail closed until a human splits the section into two headings.
| Recoverability | Judgment | Draft rights | Typical section |
|---|---|---|---|
3 (schema, tests, or CLI --help reconstruct the claims) |
0–1 | Model may draft | Flag tables, endpoint lists, generated config keys |
| 2 (source files exist, but mapping needs a thin template) | 0–1 | Model may draft with a pinned template | Install steps bound to a Makefile target |
| 0–1 (claims live in chat, slides, or uncommitted decisions) | any | Human must own | Roadmaps, pricing, support promises |
| any | 2–3 (policy, risk, or intent) | Human must own | Security posture, deprecation ethics, SLA language |
Recoverability 3 means a script could regenerate the same facts from HEAD without asking a person what is true. Recoverability 0 means the claims would vanish if the author's laptop were wiped. Judgment 3 means changing a single adjective could alter a legal or customer commitment. Those two axes are independent, which is why a boolean ai_generated field on the file is the wrong unit.
A checked-in section contract
Store the scores next to the page, rather than inside a chat transcript that reviewers never open. The YAML below is a worked schema: each section has an identifier, two scores, optional source globs, and an owner role. Paths are repository-relative on purpose, so the same file works in local hooks and in CI. Do not treat the comments as runtime configuration; they document intent for the humans who rescore after a merge.
# docs/_draft_rights/http-retry.yml
page: docs/reference/http-retry.md
threshold:
min_recoverability: 2
max_judgment: 1
sections:
- id: flags
heading: "Retry flags"
recoverability: 3
judgment: 0
sources:
- cmd/retry/flags.go
- cmd/retry/flags_test.go
owner: model
- id: install
heading: "Install from source"
recoverability: 2
judgment: 1
sources:
- Makefile
owner: model
- id: why-default
heading: "Why the default is three attempts"
recoverability: 0
judgment: 3
sources: []
owner: human
- id: support-window
heading: "Support window"
recoverability: 1
judgment: 3
sources:
- NOTICE
owner: human
The owner field is a projection of the two scores, not a third independent vote that authors can set to model for convenience. A later scorer must recompute owner and fail when the stored value disagrees with the threshold. Source globs are evidence pointers; they are not an allowlist of sentences the model may copy verbatim. Empty sources plus owner: model is a schema error, because recoverability cannot be two or three without files.
Six steps before any prompt is built
- Split the page at headings that already exist, and refuse to score a heading that mixes a table of flags with a policy paragraph in one block.
- Attach source globs only when a reviewer can name the file that would falsify a drafted sentence in that section.
- Assign recoverability and judgment without looking at a model output, because scores that follow a fluent draft tend to ratify the draft.
- Recompute
ownerfrom the threshold, then write the YAML; never typemodelfirst and invent scores that justify it. - Pass the model only the section identifier, the heading, and the contents of matching source files, never the human-owned Markdown body.
- Run the scorer in CI on the YAML and on any generated Markdown that still contains a blocked heading, and fail the job on mismatch.
Step five is the load-bearing constraint. If human-owned prose sits in the prompt, the model will imitate its commitments while filling a neighboring table, which reintroduces the page-flag problem at token level. Step six is what makes the matrix executable instead of a style-guide poster. Teams that skip step one will keep arguing about scores, because the unit under debate is still a mixed paragraph.
Scorer: fail closed, then list allowed ids
The following Python module is a worked example for local hooks and CI, not a benchmark of drafting quality. It loads one YAML contract, recomputes ownership, and prints the section identifiers a model is allowed to receive. Exit status is the interface: 0 means the contract is internally consistent, and 2 means a human must resplit or rescore before generation.
# tools/draft_rights.py — proposal / worked example, not a vendor SDK
from __future__ import annotations
import sys
from pathlib import Path
import yaml
ALLOWED_EXIT = 0
BLOCKED_EXIT = 2
def load_contract(path: Path) -> dict:
data = yaml.safe_load(path.read_text())
if not data or "sections" not in data or "threshold" not in data:
raise ValueError(f"{path} missing sections or threshold")
return data
def projected_owner(section: dict, threshold: dict) -> str:
rec = int(section["recoverability"])
jud = int(section["judgment"])
sources = section.get("sources") or []
if rec >= threshold["min_recoverability"] and jud <= threshold["max_judgment"]:
if rec >= 2 and not sources:
return "invalid"
return "model"
return "human"
def evaluate(contract: dict) -> tuple[list[str], list[str]]:
errors: list[str] = []
allowed: list[str] = []
threshold = contract["threshold"]
for section in contract["sections"]:
sid = section["id"]
expected = projected_owner(section, threshold)
stored = section.get("owner")
if expected == "invalid":
errors.append(f"{sid}: recoverability >= 2 requires sources")
continue
if stored != expected:
errors.append(f"{sid}: owner={stored!r} projected={expected!r}")
continue
if expected == "model":
allowed.append(sid)
return allowed, errors
def main(argv: list[str]) -> int:
if len(argv) != 2:
print("usage: draft_rights.py CONTRACT.yml", file=sys.stderr)
return 1
contract = load_contract(Path(argv[1]))
allowed, errors = evaluate(contract)
if errors:
print("draft rights blocked:", file=sys.stderr)
for item in errors:
print(f" - {item}", file=sys.stderr)
return BLOCKED_EXIT
print("allowed_section_ids:")
for sid in allowed:
print(f" - {sid}")
return ALLOWED_EXIT
if __name__ == "__main__":
sys.exit(main(sys.argv))
A matching test keeps the matrix honest when someone edits thresholds during a docs crunch. The cases below encode the four rows of the decision table, including the invalid combination of high recoverability and empty sources. Label this as a contract test for the gate, not as evidence that generated prose is correct. Correctness still requires review of the drafted section against the listed source files.
# tools/test_draft_rights.py — proposal / worked example
from draft_rights import evaluate
THRESHOLD = {"min_recoverability": 2, "max_judgment": 1}
def test_model_row_with_sources():
allowed, errors = evaluate({
"threshold": THRESHOLD,
"sections": [{
"id": "flags",
"recoverability": 3,
"judgment": 0,
"sources": ["cmd/retry/flags.go"],
"owner": "model",
}],
})
assert errors == []
assert allowed == ["flags"]
def test_human_row_on_judgment():
allowed, errors = evaluate({
"threshold": THRESHOLD,
"sections": [{
"id": "why-default",
"recoverability": 0,
"judgment": 3,
"sources": [],
"owner": "human",
}],
})
assert errors == []
assert allowed == []
def test_mismatch_and_missing_sources_fail_closed():
_, errors = evaluate({
"threshold": THRESHOLD,
"sections": [{
"id": "install",
"recoverability": 2,
"judgment": 1,
"sources": [],
"owner": "model",
}],
})
assert errors and "requires sources" in errors[0]
Run the pair locally with the commands below before wiring a generator. The second command must exit 2 if you flip why-default to owner: model without changing scores. That failure is the product: it is cheaper than deleting a published promise later.
python tools/test_draft_rights.py
python tools/draft_rights.py docs/_draft_rights/http-retry.yml
# expected on a valid contract:
# allowed_section_ids:
# - flags
# - install
CI only needs the same interpreter and the YAML files. The snippet below is a proposed job, not a claim about any particular provider's current defaults. Keep generation in a later job that reads allowed_section_ids from the scorer stdout and never constructs a prompt for another identifier.
# .github/workflows/draft-rights.yml — proposed wiring
name: draft-rights
on:
pull_request:
paths:
- "docs/**"
- "docs/_draft_rights/**"
- "tools/draft_rights.py"
jobs:
gate:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- run: pip install pyyaml pytest
- run: pytest tools/test_draft_rights.py
- run: |
fail=0
for f in docs/_draft_rights/*.yml; do
python tools/draft_rights.py "$f" || fail=1
done
exit $fail
Where a free model and free server actually participate
The scorer does not draft prose; it only decides which identifiers may leave the repository toward a model. Disclosure: This article was prepared as part of MonkeyCode's product outreach. MonkeyCode provides free model access and a free server option, which can host that narrow generation step after the gate prints allowed_section_ids. The useful property is the sequencing: score on HEAD, send source-backed sections only, and keep human-owned headings out of the prompt entirely.
Do not treat free access as a quality argument, a quota promise, or a reason to lower the recoverability threshold. A cheap drafting loop still emits unverifiable policy language if you skip the YAML contract. The server is a place to run the allowed fill, not an owner of support windows or default-retry ethics. If the scorer exits 2, the correct next action is resplitting headings, not retrying the same page on another model.
Limitations the score cannot hide
Recoverability is not correctness. A stale flags.go still yields a high score, and the model will faithfully document the stale flags until tests or reviewers catch the drift. Judgment scores are assigned by people, so two reviewers can disagree on whether a sentence is policy or description, and the YAML will not mediate that dispute. The gate also says nothing about tone, accessibility, or whether a table is the right shape for the audience.
Source globs can be gamed by pointing at a large file that barely relates to the heading, which inflates recoverability without making claims reconstructable. Mixed headings remain the most common evasion: authors hide a promise inside a flags section so the model is allowed to draft it. Numeric thresholds invite cargo-cult scoring, especially when a deadline makes owner: model look like throughput. None of those failure modes are solved by switching hosts or by adding more prompt text.
Who should not use this approach
Skip this workflow when the document is itself the source of truth, including contracts, security advisories, pricing pages, and incident customer notices. Skip it when the repository has no schemas, tests, CLIs, or make targets that could reconstruct a claim, because every section will fail closed and the YAML becomes theater. Skip it for narrative changelogs that exist to explain tradeoffs rather than to list flags. Skip it if your review process cannot resplit headings, because the two-axis score assumes a section is a single kind of claim.
Teams that need a model to invent architecture from a blank folder will not be helped by a recoverability gate, and they should not lower the threshold to pretend otherwise. Regulated writers who must keep a named human on every paragraph should keep owner: human for the whole tree and ignore generation. The method is for reference surfaces that already have machine-readable neighbors, not for turning an empty docs folder into a product story.
What to keep after the first passing run
Keep the YAML, the scorer, and the rule that human-owned bodies never enter the prompt, even when a neighboring section is allowed. Recalculate scores when source files move, not when a draft sounds confident, because confidence is not recoverability. If a blocked section is chronically needed in generated form, the fix is to create a test or schema that can prove its claims, not to edit the integer until the gate goes quiet. Wire the scorer before widening any prompt, including in a free-model loop on a free server; the score file is the control surface that actually changes outcomes.
Top comments (0)