Generated documentation stays trustworthy when models only restate extractable behavior and humans keep every public commitment. The practical split is not AI versus manual writing; it is restatement versus commitment at heading granularity. A permission map plus a speech-act filter can enforce that split in CI before a draft is merged. Teams that skip the split usually ship fluent paragraphs that invent compatibility, safety, or support promises.
Restatement is recoverable; commitment is not
A restatement can be checked against an OpenAPI path, a JSON Schema field, or a fixture status code. If the source changes, the paragraph can be regenerated without a product meeting or a legal review. A commitment cannot be recovered from those artifacts because it binds the organization to future behavior. Compatibility windows, support grades, threat models, and usage guidance all live in that second documentation lane.
Models are statistically good at turning a schema into a parameter table or an example body. They are not a source of authority for promises that customers will quote during an incident. The workflow below treats that difference as a machine-checkable contract rather than a writing-style preference. Reviewers then spend time on commitments instead of re-checking field names the schema already listed.
1. Define speech-act labels for every sentence
Assign each documentation sentence exactly one label before any model is allowed to rewrite it. The labels are mechanical on purpose so a small classifier, or even a verb list, can fail a build. Use these five classes and do not invent extra ones until the gate is boring. Extra labels feel precise in a design doc and then rot because nobody trains reviewers on them.
-
DESCRIBErestates observable interface facts such as types, required fields, default values, and HTTP status codes. -
EXAMPLEshows a request, response, CLI invocation, or fixture that a test already covers. -
PRESCRIBEtells the reader what they should do when the rule is not encoded in schema or tests. -
PROMISEbinds the vendor to compatibility, availability, support duration, or future work. -
WARNflags irreversible, security-sensitive, or data-loss behavior that needs a human owner.
A useful default is that models may emit DESCRIBE and EXAMPLE only. Everything else stays in the human-owned file, even when the prose is short. Short promises are still promises, and a one-line SLA is more dangerous than a long parameter table.
2. Publish a heading permission map
Do not scatter ownership comments through prose if a YAML map can name the headings once. Keep the map in-repo beside the docs so review applies to both the pages and the charter. The map is the only place a model is granted write permission. Missing headings inherit the commit lane, which fails closed when an outline changes.
# docs/heading-charter.yaml
version: 1
default_lane: commit
paths:
- glob: "docs/reference/**/*.md"
headings:
"Overview": commit
"Parameters": restate
"Request body": restate
"Responses": restate
"Examples": restate
"Error codes": restate
"Compatibility": commit
"Security": commit
"Limits": commit
"When to use": commit
forbidden_in_restate:
- guarantee
- always
- never
- sla
- "backward compatible"
- "supported until"
- "we will"
- "production-ready"
Restate headings still cannot use forbidden performative verbs even when the model is invited to write them. The verb list is deliberately blunt; it will over-block some honest descriptions, and that is cheaper than a silent compatibility claim. Tune the list in review after the gate has failed a few real drafts.
3. Gate drafts with a speech-act filter
The following script is a worked example, not a production classifier with linguistic recall claims. It splits Markdown by ATX headings, then rejects model output that leaves the restatement lane. Run it on the model draft, not on the human-owned file, so commit headings are not rewritten by accident. Treat a non-zero exit as a merge blocker rather than a lint warning that authors skip.
#!/usr/bin/env python3
"""speech_act_gate.py — fail if a model draft writes outside the restate lane."""
from __future__ import annotations
import argparse
import re
import sys
from pathlib import Path
import yaml
HEADING = re.compile(r"^(#{2,6})\s+(.*)\s*$")
SENTENCE = re.compile(r"(?<=[.!?])\s+")
def load_charter(path: Path) -> dict:
data = yaml.safe_load(path.read_text(encoding="utf-8"))
if not data or "paths" not in data:
raise ValueError("heading-charter.yaml must contain paths")
return data
def heading_lane(charter: dict, title: str) -> str:
for entry in charter["paths"]:
lanes = entry.get("headings") or {}
if title in lanes:
return lanes[title]
return charter.get("default_lane", "commit")
def sentences(text: str) -> list[str]:
chunks = [s.strip() for s in SENTENCE.split(text) if s.strip()]
return chunks or ([text.strip()] if text.strip() else [])
def looks_like_promise(sentence: str, forbidden: list[str]) -> bool:
lower = sentence.lower()
return any(token in lower for token in forbidden)
def scan(draft: str, charter: dict) -> list[str]:
forbidden = [t.lower() for t in charter.get("forbidden_in_restate", [])]
errors: list[str] = []
current = "(lead)"
lane = heading_lane(charter, current)
buf: list[str] = []
def flush() -> None:
body = "\n".join(buf).strip()
if not body:
return
if lane == "commit":
errors.append(f"commit heading {current!r} must stay human-owned")
return
for sent in sentences(body):
if looks_like_promise(sent, forbidden):
errors.append(
f"restate heading {current!r} contains forbidden phrasing: {sent}"
)
for line in draft.splitlines():
match = HEADING.match(line)
if match:
flush()
buf = []
current = match.group(2).strip()
lane = heading_lane(charter, current)
continue
buf.append(line)
flush()
return errors
def main() -> int:
parser = argparse.ArgumentParser()
parser.add_argument("--charter", type=Path, required=True)
parser.add_argument("--draft", type=Path, required=True)
args = parser.parse_args()
errors = scan(args.draft.read_text(encoding="utf-8"), load_charter(args.charter))
if errors:
print("speech-act gate failed:")
for err in errors:
print(f" - {err}")
return 1
print("speech-act gate passed")
return 0
if __name__ == "__main__":
sys.exit(main())
Install the one extra dependency and run the gate against a sample draft before wiring it to CI. Keep the draft file out of docs/reference until the exit code is zero and a human has filled commit headings.
python3 -m pip install pyyaml
python3 speech_act_gate.py \
--charter docs/heading-charter.yaml \
--draft /tmp/model-draft.md
echo $?
A non-zero exit status means the draft wrote a commit heading or smuggled a performative verb into a restatement. Do not merge that file; send only the failing headings back to a human. Passing the gate does not mean the types match the schema, so keep a separate schema diff in the same pipeline.
4. Run restatement and commitment as two jobs
Keep the model prompt boring and source-bound. Feed the schema fragment for one heading, ask for a table or example, and refuse narrative glue. Then merge with the human-owned commitment file using a structure that git can review. Mixing both lanes in one chat transcript is how promises appear beside parameter tables.
When the extractor and the speech-act gate need a remote runner, a workspace with free model access and a free server option can keep the loop off a local workstation. Disclosure: This article was prepared as part of MonkeyCode's product outreach. MonkeyCode is useful in this workflow only as that runner: host the charter, generate restatement headings, and leave commitment headings empty for humans. The free model still cannot invent a support window, and the free server does not review legal language.
A minimal two-job shape looks like the following numbered sequence. Each step should be a separate CI task so a failure does not silently skip the human lane.
- Extract the OpenAPI path, required fields, and fixture status codes for the page under change.
- Render a stub Markdown file that contains restate headings as empty sections and commit headings as
<!-- HUMAN -->markers. - Ask the model to fill only the empty restate sections, with the charter pasted as a hard constraint.
- Run
speech_act_gate.pyon the model output and drop the job on failure. - Copy human-owned paragraphs from the previous release into the commit headings without regeneration.
- Open a review that diffs restate and commit lanes in two files so reviewers do not skim past promises.
docs/reference/orders.md # merged page
docs/reference/orders.restate.md # model output, gated
docs/reference/orders.commit.md # human signed, never model-written
docs/heading-charter.yaml
Reviewers should reject a pull request that rewrites orders.commit.md from a chat transcript. The commit file is the product surface; the restate file is compiler output. If the merged page is the only artifact in git, ownership will collapse the first time someone asks a model to "refresh the whole document."
Proposed restatement prompt, labeled as unexecuted instruction text rather than a measured template:
Fill only headings whose lane is restate in heading-charter.yaml.
Use the supplied OpenAPI fragment and fixture as the only facts.
Output Markdown tables or fenced examples. Do not write Overview,
Compatibility, Security, Limits, or When to use. Do not use the
forbidden_in_restate verbs. If a fact is missing, leave a TODO.
Decision table: who writes the paragraph
The table is the review checklist, not a quality score. If a cell says the model may draft, the gate still scans for forbidden verbs. If a cell says the human must own the text, the model job must not open that heading.
| Heading or claim type | Lane | Model may draft? | Human must own? | Gate check |
|---|---|---|---|---|
| Parameter names, types, defaults | restate | yes | review only | match schema names |
| Example request from a fixture | restate | yes | review only | fixture round-trip |
| Error code list from the spec | restate | yes | review only | status codes exist |
| "Always eventually consistent" | commit | no | yes | forbidden verb |
| Supported-until date | commit | no | yes | commit heading |
| Threat model and authz rules | commit | no | yes | commit heading |
| When to pick this endpoint | commit | no | yes | commit heading |
| Rate limits advertised to customers | commit | no | yes | commit heading |
| Retry guidance not encoded in tests | commit | no | yes | PRESCRIBE blocked |
Use the table during review by walking rows, not by rereading the whole page as prose. A reviewer who only skims tone will miss a one-line promise sitting under Examples. A reviewer who checks lane membership will catch that class of error without debating writing style.
Charter coverage test after heading renames
The method assumes heading titles are stable enough to appear in YAML. If writers rename "Compatibility" to "Longevity" without updating the charter, the default commit lane should block generation, which is safer than a silent miss. Keep a unit test that loads every reference page and asserts each ## heading is listed or intentionally defaulted.
# test_charter_covers_headings.py
from pathlib import Path
import re
import yaml
HEADING = re.compile(r"^##\s+(.*)\s*$", re.M)
def test_reference_headings_are_named_or_default_commit():
charter = yaml.safe_load(Path("docs/heading-charter.yaml").read_text())
named = set()
for entry in charter["paths"]:
named.update((entry.get("headings") or {}).keys())
missing = []
for page in Path("docs/reference").rglob("*.md"):
if page.name.endswith(".restate.md"):
continue
for title in HEADING.findall(page.read_text(encoding="utf-8")):
if title not in named and charter.get("default_lane") != "commit":
missing.append((str(page), title))
assert missing == [], missing
That test does not score writing quality. It only proves the permission map still matches the outline after a refactor. Pair it with a schema diff so restatement pages cannot drift from operationIds that no longer exist.
What this workflow does not cover
The speech-act gate is a string filter plus a heading map, not a proof of correctness. Homonyms slip through, and a model can promise harm using allowed verbs. Schema-backed restatement still needs a human skim when examples could leak customer data or reproduce a signed fixture that should stay internal.
Do not use this approach as a substitute for legal review on contracts, privacy notices, or regulated claims. Do not apply it to marketing pages whose job is persuasion rather than recoverable description. Skip it for a one-file README with no public compatibility story; the charter overhead is larger than the risk. Teams without an OpenAPI document, schema, or fixture set should not invite a model to invent the restatement lane either.
Closing note for operators
Start with the heading charter, not with a prompt that asks for a complete page. Restate what a schema already admits, and keep every customer-facing promise in a file no model is allowed to touch. If you already have a free remote coding server, run speech_act_gate.py on one reference page and inspect the commit-lane leftovers before expanding the map.
Top comments (0)