DEV Community

Avery Lin
Avery Lin

Posted on

Compile OpenAPI Field Tables; Keep Compatibility Calendars Human-Signed

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
Enter fullscreen mode Exit fullscreen mode

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())
Enter fullscreen mode Exit fullscreen mode

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 |
Enter fullscreen mode Exit fullscreen mode

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])))
Enter fullscreen mode Exit fullscreen mode

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.

  1. Happy path. A two-operation spec with local $ref values 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.
  2. Empty description. A property without description must remain an empty cell rather than receiving a model-written reliability claim. The optional polish step is skipped when source text length is zero.
  3. Commitment leak. Place must remain available in a schema description and expect COMPILE_BLOCKED plus a pointer to that field. The same string inside COMPATIBILITY.md is allowed because humans own that file.
  4. Unsigned operation. Remove one operationId from 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.
  5. External $ref. Point a schema at https://example.invalid/untrusted.json and 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)" != ""
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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)