Generated reference pages fail when restated signatures share paragraphs with operational promises nobody on the product team approved. A sentence type system separates Restate, Example, and Commit before any model is allowed to write. Restate sentences only slot-fill from a symbol table. Example sentences must parse and name public fixtures. Commit sentences stay human-authored and never leave a generation step.
This workflow treats documentation as a small typed language rather than as free prose that a chat window happens to emit. Reviewers then inspect type errors instead of rereading every adjective. The pipeline remains useful even if the drafting model is replaced, because the checker owns the boundary, not the model.
Why untyped sentences leak promises
A typical generation prompt asks for a complete page covering parameters, errors, examples, and operational guidance in one pass. The model then places will, always, and guaranteed beside tables that only describe types. Readers cannot see which lines are mechanical restatements and which lines are support commitments.
Three failure modes show up in that mixed output. First, a changed return type updates the table while a nearby sentence still promises older behavior. Second, an example imports a helper that is not part of the public package. Third, a troubleshooting note invents a retry policy that no runbook actually contains.
Human review does not reliably catch those mixes under time pressure. Reviewers either rubber-stamp fluent paragraphs or rewrite the whole page and discard the generation step. A type at the sentence layer makes the mix visible as a build failure rather than as a taste debate.
Three sentence types
Every sentence that may appear in generated reference material receives exactly one type. The type is assigned from evidence, not from the model’s confidence score, and it constrains both vocabulary and source.
- Restate — A sentence that names a public symbol and repeats extractable facts: arity, types, defaults, documented exceptions, status codes, or field nullability. It may use only a closed template list. It may not introduce timing, durability, or support language.
- Example — A fenced block or a caption that points at a fixture file. The block must parse in the declared language. Every call name in the block must exist in the symbol table.
- Commit — A sentence about what the product will do, how incidents will be handled, which versions remain supported, or what callers should rely on in production. This type is handwritten in a separate file and is never a model output.
If a candidate sentence matches none of the three types, it is not a documentation defect to wordsmith. It is unclassified input and it stays out of the generated page until a human files it as Commit or drops it.
Decision table for borderline lines
| Candidate line | Evidence required | Assigned type | If evidence is missing |
|---|---|---|---|
listUsers(limit: int = 50) -> Page[User] |
Signature in the public stub or schema | Restate | Fail the build; do not paraphrase |
The client retries transient 503s |
Runbook or SLA file owned by humans | Commit | Leave a TODO(commit) stub |
users = client.list_users(limit=10) |
Fixture file that imports the public client | Example | Drop the block |
This is the easiest way to scale reads |
None that a checker can verify | Unclassified | Delete |
tokens expires after 3600 seconds |
Constant or schema field with that value | Restate | Fail if the constant differs |
The table is the policy. Models do not get a fourth informal type called “helpful tone.”
Workflow
The following steps compile a typed page. Each step writes an artifact on disk so a later step can fail closed.
- Extract a symbol table. Parse the public OpenAPI file or the exported Python stubs. Record name, parameters, defaults, return type, and declared errors. Do not parse README files at this stage, because they already mix types.
-
Load Commit files as opaque blobs. Store support promises, deprecation calendars, and retry policies in
commit/*.mdwith explicit owners. The generator may copy these files verbatim. It may not rewrite them. - Classify each outline heading. Parameter tables and error catalogs are Restate lanes. “Quick start” is an Example lane. “Production guarantees” is a Commit lane. Headings without a lane are rejected.
- Fill Restate templates only. For each symbol, instantiate templates from the table. Reject any template instantiation that interpolates a token absent from the symbol record.
-
Attach Example blocks from fixtures. Read
fixtures/public/*.pyor equivalent. Parse them. Confirm every call target is in the symbol table. Copy the source into fences. Do not let a model invent a new snippet. - Lint the assembled Markdown. Scan for modal verbs and SLA nouns inside Restate and Example regions. Fail the build on a match. Allow those words only inside copied Commit files.
The output of step 6 is the only file that publishing tools should see. Intermediate JSON is kept for diffs in review.
Artifact: a sentence type checker
The checker below is a compact, runnable sketch. It is labeled as a starting implementation, not as production metrics from a live docs corpus. Place it next to the schema and run it in CI on every spec change.
# doc_sentence_types.py
from __future__ import annotations
import ast
import json
import re
from dataclasses import dataclass
from pathlib import Path
RESTATE_TEMPLATES = (
"{name} accepts {params} and returns {returns}.",
"{name} raises {error} when the request is rejected by the service.",
"The default value of {param} on {name} is {default}.",
)
COMMIT_LEAK = re.compile(
r"\b(will|must|always|never|guarantee[ds]?|uptime|SLA|we promise)\b",
re.I,
)
@dataclass(frozen=True)
class Symbol:
name: str
params: str
returns: str
errors: tuple[str, ...]
defaults: dict[str, str]
def load_symbols(path: Path) -> dict[str, Symbol]:
raw = json.loads(path.read_text())
return {
item["name"]: Symbol(
name=item["name"],
params=item["params"],
returns=item["returns"],
errors=tuple(item.get("errors", [])),
defaults=item.get("defaults", {}),
)
for item in raw
}
def fill_restate(symbol: Symbol) -> list[str]:
lines = [
RESTATE_TEMPLATES[0].format(
name=symbol.name, params=symbol.params, returns=symbol.returns
)
]
for error in symbol.errors:
lines.append(RESTATE_TEMPLATES[1].format(name=symbol.name, error=error))
for param, default in symbol.defaults.items():
lines.append(
RESTATE_TEMPLATES[2].format(
param=param, name=symbol.name, default=default
)
)
return lines
def assert_example_parses(source: str, symbols: dict[str, Symbol]) -> None:
tree = ast.parse(source)
called = [
node.func.attr if isinstance(node.func, ast.Attribute) else node.func.id
for node in ast.walk(tree)
if isinstance(node, ast.Call) and isinstance(node.func, (ast.Name, ast.Attribute))
]
unknown = [name for name in called if name not in symbols and name not in dir(__builtins__)]
if unknown:
raise SystemExit(f"example calls unknown symbols: {unknown}")
def lint_regions(restate_text: str, example_text: str) -> None:
leaked = []
for label, text in (("restate", restate_text), ("example", example_text)):
for match in COMMIT_LEAK.finditer(text):
leaked.append(f"{label}:{match.group(0)}")
if leaked:
raise SystemExit(f"commit language leaked into typed lanes: {leaked}")
def assemble(symbol_path: Path, fixture_path: Path, commit_path: Path, out: Path) -> None:
symbols = load_symbols(symbol_path)
restate = []
for symbol in symbols.values():
restate.extend(fill_restate(symbol))
example = fixture_path.read_text()
assert_example_parses(example, symbols)
lint_regions("\n".join(restate), example)
commit = commit_path.read_text() # copied, never rewritten
body = [
"# Reference",
"## Restate",
*restate,
"## Example",
"```
python",
example.rstrip(),
"
```",
"## Commit",
commit,
]
out.write_text("\n\n".join(body) + "\n")
if __name__ == "__main__":
assemble(
Path("symbols.json"),
Path("fixtures/public/list_users.py"),
Path("commit/support.md"),
Path("generated/reference.md"),
)
A matching symbols.json record is deliberately boring. Boring is the point: if a field cannot be extracted, it does not become a Restate sentence.
[
{
"name": "list_users",
"params": "limit: int = 50, cursor: str | None = None",
"returns": "Page[User]",
"errors": ["Unauthorized", "RateLimited"],
"defaults": {"limit": "50"}
}
]
Run the checker as a gate, not as a formatter. Formatting can hide type errors; a non-zero exit cannot.
python doc_sentence_types.py
rg -n "will|guarantee|SLA" generated/reference.md
# Commit section may match; Restate and Example must not.
If rg reports a match above the Commit heading, treat it as a checker bug and extend COMMIT_LEAK rather than editing the sentence by hand in the generated file.
Where a model is allowed to write
The model’s legal surface is narrow: choose which Restate template applies when several templates are valid, and normalize punctuation inside those templates. It does not invent templates. It does not caption examples that failed to parse. It does not shorten Commit files for tone.
Disclosure: This article was prepared as part of MonkeyCode's product outreach. When Restate filling is this constrained, MonkeyCode’s free model access and free server option are enough to run the slot-filler and the checker beside CI, without standing up a separate inference budget for prose. The value is the typed boundary. The hosted model is only a convenient executor for template instantiation.
Keep Commit files in ordinary human review, the same way production runbooks are reviewed. A free endpoint does not change who is allowed to promise uptime, support windows, or migration dates.
Limitations
The type system cannot recover facts that the schema omitted. Overloaded methods, content-negotiated responses, and side effects documented only in ticket threads will stall at extraction. In those cases the honest output is a missing Restate line, not a guessed sentence.
Templates also encode English word order. Teams shipping reference docs in multiple languages need one template list per locale, still bound to the same symbol table. Translating Commit files is editorial work and stays outside the generator.
Modal-verb linting is a crude proxy for promises. A sentence can commit without will, and a Restate sentence can contain must when quoting a protocol requirement. Maintain an allowlist of quoted protocol tokens if that collision appears. Do not disable the lint globally to silence one false positive.
The example parser understands Python ast only. Other languages need their own parse-and-resolve step. Copying an unparsed fence as “documentation” reintroduces the original problem under a different heading.
Who should skip this workflow
Skip it for narrative posts, architecture decision records, and tutorials whose purpose is judgment rather than restatement. Those documents are Commit-shaped by default, and a template grammar will either refuse to generate them or flatten them into empty tables.
Skip it when the public surface is unstable and the symbol table changes faster than reviewers can update Commit files. The checker will fail closed, which is correct, but a team that cannot staff Commit ownership will only collect red builds.
Skip it if legal or compliance text must be generated from a model for speed. That requirement conflicts with the Commit rule. Route those pages to counsel and keep them out of the Restate lane.
Typed sentences make generated reference docs reviewable because each line has a source and a permitted vocabulary. Untyped pages will keep mixing tables with promises, and fluency will continue to hide the mix. If Restate filling is already mechanical, a free model on a free server can instantiate slots while Commit files remain ordinary reviewed text.
Top comments (0)