Generated documentation stays reviewable only when normative claims are rendered from a human ledger and the model is limited to narration. A page that mixes support promises, version pins, and explanatory prose hides the sentences a reviewer must actually own. This workflow compiles a claim-class manifest first, rejects unmarked sections, and lets a model fill only the classes marked draftable. The resulting pull request can trace each promise to a named owner before any generated paragraph is merged.
Why mixed drafts fail a merge gate
A documentation generator can rewrite a README, a configuration guide, or an internal library page in a single pass. That speed is useful for wording, and it is unsafe for facts that change support, security, or compatibility. Reviewers then search fluent paragraphs for implied guarantees, which is a weak control compared with a failing check. The practical failure is an unowned sentence that later readers will treat as a commitment the repository never accepted.
Sampling a generated page does not scale once teaching text and operational claims share the same file. A phrase such as "works on current long-term releases" can outlive the matrix the team actually tests. Classifying sections before generation makes that phrase either disallowed in narration or explicit in a ledger row. The extra manifest is small relative to the page, and it gives the reviewer a stable place to inspect promises.
Five classes, one writer each
The manifest defines five claim classes, and each class has exactly one allowed writer for the published page. Narration covers motivation, reading order, and conceptual explanation, so a model may draft it after the ledger is locked. Unverified examples cover illustrative commands, so a model may draft them only when an explicit unverified label stays in the section. Verified examples cover commands with an expected result, so a human owns the fixture and a model may only cite its identifier.
Normative rows cover versions, support windows, authentication requirements, breaking-change rules, and license grants, so only a human may author those fields. Pointer rows cover links to source files, so a deterministic renderer emits them from the manifest rather than from free prose. The RFC 2119 rules and the RFC 8174 update describe how specifications mark normative keywords such as MUST and SHOULD. Those documents remain the right reference when a page is itself a specification, and the RFC Editor publishes both texts.
This workflow does not replace that keyword convention inside a standards-track document or any other protocol specification. It applies a narrower split to everyday project guides, where most pages never declare which sentences a reader may rely on. When a section could sit in either narration or the normative class, the safer default is the normative class. That default adds a few ledger rows and avoids letting a fluent draft widen a support pin during rewriting.
A later edit can move a section toward narration only after the owner confirms that no reader would treat it as a promise. The checker enforces the recorded class, not the author's intention at the moment the draft was requested. Moving a section without updating the manifest is a failed build, which is preferable to a silent class change. Reviewers should treat that failure as a routing error, not as a comment on writing quality or tone.
| Class | Allowed writer | May state support or versions | Required fields |
|---|---|---|---|
| narration | model, after lock | no | id, sources |
| example_unverified | model, with label | no | id, label text |
| example_verified | human fixture | only inside the fixture | fixture_id, expected |
| normative | human ledger | yes, as ledger fields | owner, reviewed_on, source |
| pointer | renderer | no | path, git_ref |
Lock the inventory before prompting
Store the ledger at docs/claim-inventory.yaml in the same pull request as the page that will be generated. Generation must not start while locked is false, and a normative row without an owner fails closed. Dates in the sample are review metadata that a human types, not inferred uptime, quotas, or product availability. Replace the owner, the paths, and the runtime pin with values that the repository actually maintains.
page: docs/config-guide.md
locked: true
sections:
- id: why-this-exists
heading: "Why this page exists"
claim_class: narration
sources:
- config/schema.json
- id: support-matrix
heading: "Support matrix"
claim_class: normative
owner: docs-maintainers
reviewed_on: 2026-10-09
source: docs/support-matrix.yaml
fields:
runtimes:
- {name: python, range: "3.11"}
auth: "token required for write routes"
- id: sample-call
heading: "Sample call"
claim_class: example_unverified
label: "illustrative only; not a support promise"
- id: schema-link
heading: "Schema source"
claim_class: pointer
path: config/schema.json
git_ref: HEAD
The runtime pin is deliberately narrow so that a later prose pass cannot silently widen the tested range. If the tested range changes, a human edits the source file, updates reviewed_on, and regenerates the normative block. The model is not an editor of that block, even when the surrounding introduction needs a complete rewrite. This split is the reason promises are rendered before any generation prompt is sent to a model.
The sample pin is fixture data for the checker, not a statement of which runtime is current on any particular date. A pointer row that still says HEAD is not reproducible after later commits land on the branch. Replace that ref with the commit the reviewer actually read before the page is merged. Otherwise the rendered link can drift away from the schema the normative block described.
Steps from ledger to pull request
- Compile the inventory before any prompt is sent, and fail the build when a normative row lacks an owner.
- Render normative and pointer sections with a deterministic template before a model is allowed to run at all.
- Build a prompt that lists only narration and unverified-example identifiers, with rendered promises attached as read-only context.
- Require the model to return Markdown for those identifiers alone, and discard any response that adds a new heading.
- Run the phrase gate on draftable sections, then open the pull request with the manifest, fixtures, and rendered page together.
Each step is a separate command so a reviewer can rerun the gate without trusting the draft narrative. The model never receives write permission on the ledger, the support matrix, or the verified example fixtures. If a response introduces a new heading, the checker treats that heading as unmarked and fails the build immediately. Hand-merging stray sentences back into the page defeats the class split, so the correct action is to drop the response and rerun.
A local checker you can rerun
The following script is a proposal for local or continuous-integration use, and it does not call a model or a network service. It checks class coverage, required owner fields, and a short denylist inside sections the model is allowed to draft. Extend the denylist with product-specific promise words, because a fixed expression cannot catch every implication a paragraph might carry. Treat a green run as a necessary merge condition, not as proof that the narration is semantically safe for readers.
#!/usr/bin/env python3
"""Proposal: block normative wording inside draftable doc sections."""
import re
import sys
import pathlib
import yaml # PyYAML, reviewed dependency
DENY = re.compile(
r"\b(supported|guaranteed|production-ready|warranty|"
r"generally available|officially)\b",
re.IGNORECASE,
)
DRAFTABLE = {"narration", "example_unverified"}
def section_body(page: str, heading: str) -> str:
marker = f"## {heading}"
chunk = page.split(marker, 1)[1]
return chunk.split("\n## ", 1)[0]
def main(manifest_path: str, page_path: str) -> int:
data = yaml.safe_load(pathlib.Path(manifest_path).read_text())
if not data.get("locked"):
print("inventory is not locked")
return 1
page = pathlib.Path(page_path).read_text()
for spec in data["sections"]:
if f"## {spec['heading']}" not in page:
print("missing heading", spec["heading"])
return 1
kind = spec["claim_class"]
if kind == "normative" and not (spec.get("owner") and spec.get("reviewed_on")):
print("unowned normative section", spec["id"])
return 1
if kind not in DRAFTABLE:
continue
body = section_body(page, spec["heading"])
hit = DENY.search(body)
if hit:
print(f"denied term {hit.group(0)!r} in {spec['id']}")
return 1
label = spec.get("label", "")
if kind == "example_unverified" and label.lower() not in body.lower():
print("missing unverified label", spec["id"])
return 1
print("claim-class gate passed")
return 0
if __name__ == "__main__":
sys.exit(main(sys.argv[1], sys.argv[2]))
Install the YAML parser, then point the gate at the manifest and the rendered page using the commands below. A passing run prints a single status line and exits with code zero, which a job can require before merge. A narration block that contains "supported" or "guaranteed" fails before a human spends review time on tone alone. Keep RFC 2119 verbs out of narration by review policy, because this sample expression intentionally does not fail the build on "must".
python3 -m pip install pyyaml
python3 tools/claim_class_gate.py docs/claim-inventory.yaml docs/config-guide.md
A deterministic renderer can stay smaller than the gate, because it only prints ledger fields and refuses to paraphrase them. The sketch below writes the support section from the manifest so the model cannot restate the runtime pin in its own words. Wire that function into step two, and commit its output beside the inventory so reviewers can diff the promises directly. If the function and the page disagree, trust the inventory and regenerate, rather than editing the rendered sentences by hand.
def render_normative(spec: dict) -> str:
fields = spec["fields"]
lines = [
f"## {spec['heading']}",
"",
"Values below are copied from the claim inventory.",
]
for item in fields["runtimes"]:
lines.append(f"- Runtime pin: {item['name']} {item['range']}")
lines.append(f"- Auth rule: {fields['auth']}")
lines.append(
f"- Owner: {spec['owner']}; reviewed_on: {spec['reviewed_on']}"
)
return "\n".join(lines) + "\n"
Using free model access without widening claims
Disclosure: This article was prepared as part of MonkeyCode's product outreach. MonkeyCode's free model access and free server option fit this workflow because the hard part is a locked prompt plus a repeatable check. Send a free model only the draftable section identifiers, and include the rendered normative blocks as context that the model must not rewrite. Run the checker and the renderer on the free server whenever the page changes, so one laptop is not the only place the gate can run.
Keep the generation instruction narrow enough that a reviewer can audit it after a failed run. Ask for Markdown under the listed headings, forbid new headings, and forbid support, version, license, and availability claims in those sections. If the response adds a heading or edits a normative block, discard it and rerun rather than splicing sentences by hand. Store the checker log with the pull request so the reviewer can see each section class without rereading every paragraph.
Free access does not relax the ownership rule, and a green checker does not certify that the prose is safe. A missing owner still fails the build, whether the command runs locally or on the free server option. This description names neither a model nor a quota nor a server size, because those details were not verified inputs. If the inventory is already locked, a free model can draft only the narration sections that the manifest names.
Limits, noisy terms, and teams that should not start here
The phrase gate is a heuristic, and a careful paragraph can imply a promise without using any denied word. A line such as "teams already depend on this path for live traffic" may pass the sample expression and still over-claim. Human review of narration remains necessary, especially when readers include customers rather than internal contributors only. Heading matching is also brittle, so a renderer that changes punctuation will report mismatches until both sides use the same string.
Skip this approach for security advisories, compliance attestations, and any license text that still needs legal counsel. Skip it when nobody will accept the owner field, because the file would then imitate an audit trail without a responsible person. Skip it as the only control on executable examples, since verified fixtures still need a test that runs the command and compares the expected result. The sample denylist is English-only, so a multilingual guide needs a term list maintained by the same owners who edit the ledger.
A repository that already ships a formal specification should keep its existing RFC 2119 review on that specification. This router is intended for the surrounding guides that models are now asked to rewrite between releases. Start with one page, record which failures were true over-claims versus noisy teaching words, and only then extend the expression. That measurement stays local to the repository, and it should not be replaced with a generic score from an unrelated benchmark.
Top comments (0)