Generated reference docs stay trustworthy only when models compile field tables from schemas instead of inventing product promises. Compatibility calendars, deprecation dates, and side-effect warnings remain human-signed because no $ref encodes those promises. This article describes a small compiler that walks OpenAPI documents into markdown field tables with source pointers. A companion linter then rejects commitment verbs so draft output cannot quietly become a support contract.
Why field tables and calendars diverge
OpenAPI already records types, required flags, enumerations, and example payloads for each operation and schema. Those facts can be compiled mechanically, and a model may only rephrase descriptions that already exist beside the fields. Support windows, regional data residency, and destructive side effects do not appear as typed nodes in typical specifications. Treating those absences as license to improvise is how generated READMEs become accidental SLAs during later customer reviews.
A useful split is therefore mechanical versus promissory, not “AI versus human” as a personality contest. Mechanical rows can be rebuilt from one frozen specification blob on every pull request. Promissory rows need dates, owners, and a signature because they constrain what the product will still do next quarter. Mixing both kinds of sentence in one generated chapter hides the unsigned claims inside otherwise accurate tables.
A small documentation IR
Represent each generated unit as a row with operation, pointer, origin, and a closed set of allowed verbs. Allowed origins are schema, example, and test-name; everything else stays outside the compiler and awaits a human signature. Allowed model verbs are describe, tabulate, and quote; verbs such as guarantee, support, and deprecate stay blocked. This intermediate representation is small enough to diff in pull requests and strict enough to fail continuous integration on leakage.
The decision table below is the contract the compiler implements. Reviewers should reject any extra column that cannot be rebuilt from the specification or from a named test.
| Doc unit | Evidence | Emitter | Signer |
|---|---|---|---|
| Field name, type, required | JSON Schema properties / required
|
compiler | none (mechanical) |
| Field description polish | non-empty schema description
|
optional model rewrite | reviewer if wording changed |
| Example payload |
example or examples on the schema |
compiler | none |
| Operation inventory |
paths plus operationId
|
compiler | none |
| Deprecation date / overlap window | not a typed OpenAPI node | blocked | human calendar |
| Auth meaning beyond scheme names |
securitySchemes keys only |
compiler lists names | human explains implications |
| Destructive side effects | not typed | blocked | human calendar |
| SLA, residency, pricing | none | blocked | human-owned files |
Procedure
The following sequence keeps generation deterministic even when a model polishes grammar after the compiler emits tables.
1. Freeze the specification blob
The compiler must hash openapi.json before emission so later reviews can see whether tables drifted from schema. Record that hash beside the generated markdown so reviewers compare IR rows against one immutable blob.
mkdir -p docs
sha256sum openapi.json | awk '{print $1}' > docs/openapi.sha256
2. Walk paths and $ref graphs
Resolve only local #/ pointers during compilation because external documents often mix marketing language with typed fields. External $ref targets remain human-owned until a maintainer copies them into the frozen specification blob for this release.
3. Emit IR rows, not prose chapters
Each row stores operation, field name, type, required flag, JSON pointer, and a single origin token. Prose chapters such as tutorials and onboarding narratives are out of scope for this compiler on purpose.
4. Optional model polish of existing descriptions
A model may rewrite a schema description only when the source text is non-empty and the rewrite preserves types. Empty descriptions stay empty rather than becoming invented product claims about reliability, latency, or supported versions.
5. Lint commitment verbs before merge
Reject drafts that contain RFC 2119 verbs, calendar dates, or phrases that imply a support window. Those tokens belong in COMPATIBILITY.md, which the generator must never open for write during a docs compile.
6. Human-sign the compatibility calendar
Maintainers add deprecation dates, overlap windows, and side-effect notes in a short table keyed by operationId. Continuous integration fails when an operationId appears in compiled tables but is missing from that calendar.
Compiler artifact
Save the walker as tools/compile_field_tables.py. The script is a compiler first; any later model pass may only polish non-empty description values already present on the schema.
#!/usr/bin/env python3
from __future__ import annotations
import json
import re
import sys
from pathlib import Path
from typing import Any, Iterator
COMMITMENT = re.compile(
r"\b(must|shall|should|may|guarantee|sla|supported until|"
r"we promise|backward compatible|will not break)\b",
re.I,
)
DATEISH = re.compile(r"\b(20\d{2}-\d{2}-\d{2}|Q[1-4]\s*20\d{2})\b")
ALLOWED_ORIGINS = {"schema", "example", "test-name"}
METHODS = {"get", "post", "put", "patch", "delete", "head", "options"}
def load_spec(path: Path) -> dict[str, Any]:
text = path.read_text(encoding="utf-8")
if path.suffix in {".yaml", ".yml"}:
import yaml
return yaml.safe_load(text)
return json.loads(text)
def resolve_ref(spec: dict[str, Any], ref: str) -> dict[str, Any]:
if not ref.startswith("#/"):
raise ValueError(f"external refs are human-owned: {ref}")
node: Any = spec
for part in ref[2:].split("/"):
node = node[part.replace("~1", "/").replace("~0", "~")]
return node
def iter_operations(spec: dict[str, Any]) -> Iterator[tuple[str, str, dict[str, Any]]]:
for path, item in (spec.get("paths") or {}).items():
if not isinstance(item, dict):
continue
for method, op in item.items():
if method.lower() not in METHODS or not isinstance(op, dict):
continue
yield path, method.upper(), op
def schema_fields(spec: dict[str, Any], schema: dict[str, Any], pointer: str) -> list[dict[str, Any]]:
if "$ref" in schema:
ref = schema["$ref"]
return schema_fields(spec, resolve_ref(spec, ref), ref)
required = set(schema.get("required") or [])
rows: list[dict[str, Any]] = []
for name, prop in (schema.get("properties") or {}).items():
if "$ref" in prop:
target = resolve_ref(spec, prop["$ref"])
typ = target.get("type", "object")
desc = (prop.get("description") or target.get("description") or "").strip()
ptr = prop["$ref"]
else:
typ = prop.get("type", "unknown")
desc = (prop.get("description") or "").strip()
ptr = f"{pointer}/properties/{name}"
rows.append(
{
"name": name,
"type": typ,
"required": name in required,
"description": desc,
"pointer": ptr,
"origin": "schema",
}
)
return rows
def compile_ir(spec: dict[str, Any]) -> list[dict[str, Any]]:
units: list[dict[str, Any]] = []
for path, method, op in iter_operations(spec):
op_id = op.get("operationId") or f"{method}:{path}"
body = ((op.get("requestBody") or {}).get("content") or {}).get("application/json") or {}
if body.get("schema"):
pointer = f"#/paths/{path}/{method.lower()}/requestBody"
for row in schema_fields(spec, body["schema"], pointer):
units.append(
{
"operation": op_id,
"method": method,
"path": path,
"status": "request",
"kind": "request-field",
**row,
}
)
for status, media in (op.get("responses") or {}).items():
content = (media or {}).get("content") or {}
json_body = content.get("application/json") or {}
schema = json_body.get("schema") or {}
if not schema:
continue
pointer = f"#/paths/{path}/{method.lower()}/responses/{status}"
for row in schema_fields(spec, schema, pointer):
units.append(
{
"operation": op_id,
"method": method,
"path": path,
"status": str(status),
"kind": "response-field",
**row,
}
)
return units
def lint_units(units: list[dict[str, Any]]) -> list[str]:
errors: list[str] = []
for unit in units:
text = unit.get("description") or ""
loc = f"{unit['operation']} {unit['pointer']}"
if unit.get("origin") not in ALLOWED_ORIGINS:
errors.append(f"{loc}: origin {unit.get('origin')} is not compilable")
if COMMITMENT.search(text) or DATEISH.search(text):
errors.append(f"{loc}: commitment or calendar language in schema description")
return errors
def to_markdown(units: list[dict[str, Any]], digest: str) -> str:
lines = [
"# Compiled field tables",
"",
f"> unsigned compile from openapi.json sha256 `{digest}`",
"",
]
current = None
for unit in sorted(units, key=lambda row: (row["operation"], row["kind"], row["name"])):
key = (unit["operation"], unit["kind"], unit["status"])
if key != current:
current = key
lines.append(
f"## {unit['method']} {unit['path']} — {unit['kind']} ({unit['status']})"
)
lines.append("")
lines.append("| Field | Type | Required | Pointer | Description |")
lines.append("| --- | --- | --- | --- | --- |")
req = "yes" if unit["required"] else "no"
desc = (unit["description"] or "").replace("|", "\\|")
lines.append(
f"| `{unit['name']}` | `{unit['type']}` | {req} | `{unit['pointer']}` | {desc} |"
)
lines.extend(
[
"",
"Human-owned companion: `COMPATIBILITY.md` (windows, side effects, auth meaning).",
"",
]
)
return "\n".join(lines)
def calendar_gaps(units: list[dict[str, Any]], calendar_path: Path) -> list[str]:
if not calendar_path.exists():
return ["COMPATIBILITY.md is missing; humans must create the calendar"]
signed = {
line.split("|")[1].strip()
for line in calendar_path.read_text(encoding="utf-8").splitlines()
if line.startswith("|") and "operationId" not in line and "---" not in line
}
missing = sorted({unit["operation"] for unit in units} - signed)
return [f"unsigned operationId: {op}" for op in missing]
def main() -> int:
spec_path = Path(sys.argv[1] if len(sys.argv) > 1 else "openapi.json")
digest = Path("docs/openapi.sha256").read_text(encoding="utf-8").strip()
spec = load_spec(spec_path)
units = compile_ir(spec)
errors = lint_units(units) + calendar_gaps(units, Path("COMPATIBILITY.md"))
Path("docs/ir.json").write_text(json.dumps(units, indent=2), encoding="utf-8")
Path("docs/api-fields.md").write_text(to_markdown(units, digest), encoding="utf-8")
if errors:
print("COMPILE_BLOCKED")
print("\n".join(errors))
return 1
print(f"compiled {len(units)} field rows")
return 0
if __name__ == "__main__":
raise SystemExit(main())
Human-owned calendar file
Keep COMPATIBILITY.md in the same repository and outside the compiler write set. The table is intentionally short so missing dates are obvious during review rather than buried in generated prose.
# Compatibility calendar (human-signed)
| operationId | status | until | side effects | signer |
| --- | --- | --- | --- | ---
| createCharge | supported | 2027-03-31 | writes ledger rows; not idempotent without `Idempotency-Key` | @api-owners |
| getCharge | supported | 2027-03-31 | none | @api-owners |
| deleteCharge | overlapping | 2026-12-15 | irreversible; purge delay is a policy decision | @api-owners |
A second linter should scan any model-polished markdown that is not docs/api-fields.md. Generated files may not introduce section titles that belong to the calendar or to legal files.
# tools/lint_model_draft.py
from pathlib import Path
import re
import sys
FORBIDDEN_HEADINGS = {
"support matrix",
"sla",
"compatibility",
"security contact",
"pricing",
"data residency",
}
COMMITMENT = re.compile(
r"\b(must|shall|guarantee|supported until|backward compatible)\b", re.I
)
def main(path: Path) -> int:
text = path.read_text(encoding="utf-8").lower()
errors = []
for heading in FORBIDDEN_HEADINGS:
if heading in text:
errors.append(f"human-owned heading leaked: {heading}")
if COMMITMENT.search(text):
errors.append("commitment verb in model draft")
if errors:
print("\n".join(errors))
return 1
return 0
if __name__ == "__main__":
raise SystemExit(main(Path(sys.argv[1])))
Reproducible test plan
Run these checks against a fixture specification before enabling the compiler on a customer-facing repository. The cases below are labeled as a proposed suite, not as production telemetry from a live fleet.
-
Happy path. A two-operation spec with local
$refvalues should emit one IR row per property and exit status zero. The markdown tables must include JSON pointers that still resolve after the hash is recorded. -
Empty description. A property without
descriptionmust remain an empty cell rather than receiving a model-written reliability claim. The optional polish step is skipped when source text length is zero. -
Commitment leak. Place
must remain availablein a schema description and expectCOMPILE_BLOCKEDplus a pointer to that field. The same string insideCOMPATIBILITY.mdis allowed because humans own that file. -
Unsigned operation. Remove one
operationIdfrom the calendar and expect a gap error even when every field table compiled cleanly. Calendar coverage is a merge gate, not a documentation style preference. -
External
$ref. Point a schema athttps://example.invalid/untrusted.jsonand expect the walker to refuse the compile. Copying the remote document into the frozen blob is a human action.
python3 tools/compile_field_tables.py openapi.json
python3 tools/lint_model_draft.py docs/api-fields.md
test "$(cut -c1-16 docs/api-fields.md | head -n 1)" != ""
Wire the same commands into continuous integration so a regenerated table cannot merge without a calendar row. A minimal job only needs the repository checkout, Python, and the frozen specification path.
# .github/workflows/docs-compile.yml
name: docs-compile
on: [pull_request]
jobs:
compile:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- run: sha256sum openapi.json | awk '{print $1}' > docs/openapi.sha256
- run: python3 tools/compile_field_tables.py openapi.json
- run: python3 tools/lint_model_draft.py docs/api-fields.md
Where a shared draft runner fits
Disclosure: This article was prepared as part of MonkeyCode's product outreach. A compile-and-lint pipeline still needs a runner that can execute the walker without mixing in extra product claims. MonkeyCode's free model access and free server option can host that draft pass, including optional description polish against frozen schema text. The ownership split does not change: compiled tables remain unsigned, and compatibility calendars remain human-signed files in the same repository.
Limitations
This approach assumes a single canonical OpenAPI file whose examples match production payloads closely enough for reference tables. Heuristic linters miss clever paraphrases of guarantees, so reviewers still scan COMPATIBILITY.md and any polished descriptions. GraphQL, protobuf, and event catalogs need different walkers; copying this script onto those formats will drop union types. Do not use the compiler to draft SECURITY.md, pricing pages, or regional residency statements under any automation schedule.
The compiler also does not prove that handlers implement the schema they publish. Field tables can be perfectly generated from a stale specification and still mislead an integrator who trusts the repository. Pair this workflow with contract tests that bind operationId values to fixtures; those tests remain a separate artifact with a separate owner.
Who should not use this workflow
Skip this workflow if the specification is incomplete, handwritten in wiki prose, or routinely diverges from shipped handlers. Skip it if product marketing owns the reference pages and requires narrative voice instead of tabulated fields and pointers. Skip it if legal counsel must approve every customer-facing sentence, because a regex cannot replace that review. Those teams need a broader review process, not a schema compiler that only emits unsigned field tables.
Teams that already freeze OpenAPI blobs can adopt the compiler without waiting for a larger documentation platform. The useful test is simple: delete every model sentence and check whether field tables still compile from schema. If they do, the remaining human work is the calendar, not another generated chapter of promises.
Top comments (0)