DEV Community

Avery Lin
Avery Lin

Posted on

Stop Generated API Docs From Inventing Defaults the Schema Never Stated

Generated API reference should restate only field names, types, and required flags already present in OpenAPI. It should not invent default values, extra status codes, or operational retry advice for callers. A JSON Pointer validator can enforce that split before any generated draft reaches code review. The workflow below treats each reference page as a form with sealed slots and fillable slots.

The failure mode is silent invention

Large language models complete missing fields because next-token prediction prefers a fluent page over an honest gap. When a schema omits default, a draft often inserts 0, false, or "" as if production used those values. When a path documents 200 and 409 only, a draft often adds 500 with a generic apology that no owner approved. Those insertions look helpful during review and later harden into false contracts after merge.

The same completion bias produces operational language that no engineer actually committed to writing. Sentences about retry, backoff, idempotency, and uptime read like reference material while they bind support. If the source document never stated those policies, the generated page should leave the slots empty. Empty output is more honest than a fluent guess that nobody can cite.

Three lanes on every reference page

Assign every block on an API reference page to one of three lanes. Shape is mechanical: names, types, required flags, enumerated values, and documented status codes. Meaning is semantic: why a field exists, how callers should interpret empty strings, and what a 409 implies. Operations is policy: retries, rate limits, authentication, deprecation windows, and support commitments.

A model may draft Shape only when each filled slot cites a JSON Pointer into the committed OpenAPI file. Meaning stays blank when the schema description is missing, and a human writes that text later. Operations is sealed in git as human-owned strings and must not be paraphrased by any filler. Mixing the three lanes inside one prompt is exactly what produces invented defaults.

Build a page form before prose exists

Represent the page as JSON before any paragraph is generated by a model. Each slot records a lane, a JSON Pointer, a seal flag, and a small allowlist of tokens. The filler may write only into unsealed Shape slots whose pointers resolve against the pinned schema. The validator then compares the draft against the form rather than against a reviewer's memory of the API.

The example schema below is a fixture for the validator, not a claim about any production service. Status 500 is intentionally absent so the gate has something false to reject. Property priority has a type and no default, which is the usual leak that invented zeros fall through.

# fixtures/orders.openapi.yaml — worked example, not a live API
openapi: 3.0.3
info:
  title: Orders
  version: "1.2.0"
paths:
  /orders:
    post:
      operationId: createOrder
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [sku, quantity]
              properties:
                sku:
                  type: string
                  minLength: 1
                quantity:
                  type: integer
                  minimum: 1
                priority:
                  type: string
                  enum: [low, normal, high]
      responses:
        "201":
          description: Created
          content:
            application/json:
              schema:
                type: object
                required: [id, status]
                properties:
                  id:
                    type: string
                  status:
                    type: string
                    enum: [queued, reserved]
        "409":
          description: Duplicate idempotency key
Enter fullscreen mode Exit fullscreen mode

Numbered workflow

  1. Pin the OpenAPI file to a git SHA so generated Shape cannot drift across silent regenerations.
  2. Extract slots for properties, required arrays, enums, and response status codes only.
  3. Seal Operations keys such as auth, rate_limit, deprecation, and retry in a separate file.
  4. Mark Meaning slots blank when description is missing, null, or only whitespace characters.
  5. Send the model unsealed Shape slots plus the cited schema fragments, never the sealed file.
  6. Reject any draft key that lacks a pointer, invents a default, or adds an undocumented status.
  7. Merge human Meaning and sealed Operations in a later commit after a named reviewer signs off.

The order matters because regeneration is cheap and review is not. If sealed Operations text ever enters the prompt, a fluent paraphrase can slip past a diff that looks like copy editing. Keep the sealed file out of the filler context even when the drafting host is otherwise convenient to use.

Worked example: extract, fill, and reject

The scripts below are labeled examples for local reproduction. They are not reported production metrics, and they do not assert that any vendor model passed this gate. Run them against the fixture after saving fixtures/orders.openapi.yaml from the previous section.

# tools/page_form.py — example extractor, unexecuted in this article
from __future__ import annotations

import json
from pathlib import Path
from typing import Any

try:
    import yaml
except ImportError as exc:
    raise SystemExit("pip install pyyaml") from exc

SEALED_OPS = {
    "auth": "Human-owned. Do not draft.",
    "rate_limit": "Human-owned. Do not draft.",
    "retry": "Human-owned. Do not draft.",
    "deprecation": "Human-owned. Do not draft.",
}


def pointer_escape(part: str) -> str:
    return part.replace("~", "~0").replace("/", "~1")


def load_spec(path: Path) -> dict[str, Any]:
    return yaml.safe_load(path.read_text())


def shape_slots(spec: dict[str, Any]) -> list[dict[str, Any]]:
    slots: list[dict[str, Any]] = []
    paths = spec.get("paths") or {}
    for path_key, item in paths.items():
        for method, op in item.items():
            if method.startswith("x-") or not isinstance(op, dict):
                continue
            base = f"/paths/{pointer_escape(path_key)}/{method}"
            schema = (
                op.get("requestBody", {})
                .get("content", {})
                .get("application/json", {})
                .get("schema")
                or {}
            )
            required = set(schema.get("required") or [])
            props = schema.get("properties") or {}
            for name, prop in props.items():
                ptr = f"{base}/requestBody/content/application~1json/schema/properties/{pointer_escape(name)}"
                slots.append({
                    "id": f"req.{name}.type",
                    "lane": "shape",
                    "pointer": f"{ptr}/type",
                    "sealed": False,
                    "value": prop.get("type"),
                    "allow_default": "default" in prop,
                })
                slots.append({
                    "id": f"req.{name}.required",
                    "lane": "shape",
                    "pointer": f"{base}/requestBody/content/application~1json/schema/required",
                    "sealed": False,
                    "value": name in required,
                    "allow_default": False,
                })
                if "enum" in prop:
                    slots.append({
                        "id": f"req.{name}.enum",
                        "lane": "shape",
                        "pointer": f"{ptr}/enum",
                        "sealed": False,
                        "value": prop["enum"],
                        "allow_default": False,
                    })
                desc = (prop.get("description") or "").strip()
                slots.append({
                    "id": f"req.{name}.meaning",
                    "lane": "meaning",
                    "pointer": f"{ptr}/description" if desc else None,
                    "sealed": True,
                    "value": desc or None,
                    "allow_default": False,
                })
            for code in (op.get("responses") or {}):
                slots.append({
                    "id": f"status.{code}",
                    "lane": "shape",
                    "pointer": f"{base}/responses/{pointer_escape(str(code))}",
                    "sealed": False,
                    "value": str(code),
                    "allow_default": False,
                })
    return slots


def build_form(spec_path: Path) -> dict[str, Any]:
    spec = load_spec(spec_path)
    return {
        "schema_sha_command": "git rev-parse HEAD:fixtures/orders.openapi.yaml",
        "shape": shape_slots(spec),
        "operations": {k: {"lane": "operations", "sealed": True, "value": v} for k, v in SEALED_OPS.items()},
    }


if __name__ == "__main__":
    form = build_form(Path("fixtures/orders.openapi.yaml"))
    Path("artifacts/page_form.json").write_text(json.dumps(form, indent=2))
    print(f"slots={len(form['shape'])}")
Enter fullscreen mode Exit fullscreen mode
# tools/validate_draft.py — example gate, unexecuted in this article
from __future__ import annotations

import json
import re
from pathlib import Path
from typing import Any

OPS_LEAK = re.compile(
    r"\b(retry|retries|backoff|sla|uptime|guarantee|rate[- ]limit|bearer|oauth)\b",
    re.I,
)


def validate(form: dict[str, Any], draft: dict[str, Any]) -> list[str]:
    errors: list[str] = []
    shape_by_id = {row["id"]: row for row in form["shape"]}
    allowed_status = {
        row["value"] for row in form["shape"] if row["id"].startswith("status.")
    }

    for key, body in draft.get("shape", {}).items():
        row = shape_by_id.get(key)
        if row is None:
            errors.append(f"unknown shape key: {key}")
            continue
        if row["lane"] != "shape" or row.get("sealed"):
            errors.append(f"sealed or non-shape key filled: {key}")
            continue
        if not row.get("pointer"):
            errors.append(f"uncited key: {key}")
        if key.endswith(".meaning"):
            errors.append(f"meaning filled by model: {key}")
        if body.get("default") is not None and not row.get("allow_default"):
            errors.append(f"invented default on {key}: {body['default']!r}")
        text = body.get("prose") or ""
        if OPS_LEAK.search(text):
            errors.append(f"operations language in shape slot {key}")

    for code in draft.get("status_codes", []):
        if str(code) not in allowed_status:
            errors.append(f"invented status code: {code}")

    if draft.get("operations"):
        errors.append("operations block must remain sealed and human-owned")

    return errors


if __name__ == "__main__":
    form = json.loads(Path("artifacts/page_form.json").read_text())
    draft = json.loads(Path("artifacts/model_draft.json").read_text())
    problems = validate(form, draft)
    Path("artifacts/validation.json").write_text(
        json.dumps({"ok": not problems, "errors": problems}, indent=2)
    )
    if problems:
        raise SystemExit("\n".join(problems))
    print("ok")
Enter fullscreen mode Exit fullscreen mode

A failing draft for the fixture looks like the JSON below. priority receives a default the schema never stated, and 500 is added although the path never listed it. The validator should refuse both rows and refuse the retry sentence that leaked into Shape.

{
  "shape": {
    "req.priority.type": {
      "prose": "priority is a string; default is normal. Retry twice on 500.",
      "default": "normal"
    }
  },
  "status_codes": ["201", "409", "500"],
  "operations": {
    "retry": "Callers should retry with exponential backoff."
  }
}
Enter fullscreen mode Exit fullscreen mode
# example commands — run locally after creating the fixture files
python -m pip install pyyaml
mkdir -p fixtures artifacts tools
python tools/page_form.py
python tools/validate_draft.py
# expected: invented default on req.priority.type
# expected: invented status code: 500
# expected: operations language in shape slot req.priority.type
Enter fullscreen mode Exit fullscreen mode

A passing Shape draft restates string and the enum members, omits default, and lists only 201 and 409. Meaning for priority stays null until a human writes why the field exists. Operations remains the sealed file, not a paragraph the model volunteered during completion.

Decision table for fill versus seal

Page fragment Lane Model may draft? Gate
Property name, JSON type, required Shape Yes, with pointer Pointer must resolve
enum members listed in schema Shape Yes, exact set Set equality, no extras
default when the schema has default Shape Yes, copy only Value must match schema
default when the schema omits it Shape No Reject any default key
HTTP status listed under responses Shape Yes, copy only No extra codes
Empty or missing description Meaning No Slot stays blank
Auth, rate limit, retry, deprecation Operations No Sealed file, no paraphrase
Example JSON derived from a fixture hash Shape Yes, if hashed separately Out of scope here

The table is a review checklist, not a claim that every OpenAPI document is complete. Vendor extensions such as x-retry are Operations unless a team explicitly allowlists them as Shape. If the extension encodes a product promise, it still belongs in the sealed file even when the key sits beside a schema object.

Drafting Shape without feeding sealed text

The filler should receive the page form and the cited schema fragments, not the Operations file and not yesterday's published HTML. Prompt context that includes sealed strings invites paraphrase, and paraphrase is how a support date or an SLA leaks into a reference page. Keep regeneration deterministic by pinning the schema path at a git SHA and by storing the form next to that file.

Disclosure: This article was prepared as part of MonkeyCode's product outreach. MonkeyCode's free model access and free server option can host the Shape drafting step when a team wants an editor outside local laptops. The sealed Operations file and the validator still belong in the repository that owns the OpenAPI document, because availability of a drafting host does not change which lane a sentence is in.

Limitations

JSON Pointer escaping for / and ~ is easy to get wrong on nested content types. OpenAPI 3.1 schema dialects can place types in arrays, and this example only reads a string type. The leak regex is a coarse filter and will false-positive on a field actually named retry_count if that name is Shape. The gate does not prove production behavior, and it cannot see defaults that exist only in server code.

The workflow also assumes a committed machine-readable schema. Comments in handlers, wiki pages, and on-call runbooks are not pointers the extractor can resolve. If a default lives only in application code, promote it into OpenAPI first, then allow Shape to copy it. Do not ask the model to reconcile those sources, because reconciliation is judgment rather than restatement of a single file.

Who should skip this approach

Skip this pipeline when the product has no OpenAPI file, or when the public contract lives in a human-authored PDF. Skip it for tutorials, architecture decision records, incident reviews, and marketing comparison pages. Skip it for billing, legal, security questionnaires, and any document whose sentences are themselves the commitment. Those pages need named authors, not a Shape filler with a pointer allowlist.

Teams that already generate HTML from OpenAPI without a model do not need a filler at all. Use the vendor renderer for Shape, and keep Meaning and Operations in files the renderer cannot overwrite. Introducing a model only to restate types the renderer already prints adds review cost without adding a citeable fact.

What to keep in git

Keep the schema, the page form, the sealed Operations file, and the validator in the same repository. Keep generated Shape drafts out of main until the validator writes ok and a human fills blank Meaning. Ship Shape from the schema, leave Meaning blank until a person writes it, and keep Operations in a sealed file that no filler receives.

Top comments (0)