DEV Community

Avery Lin
Avery Lin

Posted on

Freeze the Public Identifier Set Before a Model Drafts API Docs

Generated API documentation stays honest only when the writer cannot invent identifiers the repository does not already export. A practical workflow therefore freezes paths, schema names, error codes, and environment keys before any model drafts prose. The model may assemble a surface map that restates those frozen nouns and cites the files that produced them. Humans retain every sentence that introduces a date, a support promise, or a compatibility window.

Prompt-only discipline fails because models complete sentences with product language that never appears in source. Comments, README slogans, and prior generated pages are especially unsafe seeds for that completion. An identifier freeze treats the public noun set as a build artifact, not as a suggestion inside a chat window. The drafting model then becomes a formatter of already extracted facts rather than an author of the public contract.

This article specifies a four-step pipeline: extract, draft, lint, and overlay. The extract step is deterministic and should run in ordinary CI. The draft step is the only place a model is useful. The lint step rejects unknown nouns and obligation verbs. The overlay step is a human-owned file that the model is not allowed to edit.

What the model may draft versus what a human must own

The split is not “technical versus marketing.” It is “present tense surface” versus “time-bound commitment.” Present tense surface can be checked against OpenAPI, tests, and exported symbols on every commit. Time-bound commitment cannot be checked that way, because no schema file contains a deprecation calendar or a support hour.

Claim class Example sentence Allowed drafter Required citation
Path and method POST /v1/orders accepts OrderCreate. Model OpenAPI path item
Field type priority is an optional string enum. Model Schema property
Error code inventory_conflict returns HTTP 409. Model Test or status map
Env key ORDERS_DB_URL is required at process start. Model Config struct or chart
Deprecation date /v1/orders remains until 2026-12-01. Human Release ticket
Support promise Critical faults receive a reply in one hour. Human Policy owner
Compatibility Clients on SDK 3 keep working through Q4. Human Compatibility charter

The table is the review contract. If a generated paragraph cannot land in the first four rows, it does not belong in the model draft. If a reviewer needs a date or a guarantee, that sentence moves to the human overlay before merge.

Step 1: Extract a frozen identifier set

Start from files the compiler or the contract test already trusts. Do not start from comments, because comments drift without failing a build. OpenAPI documents, generated clients, and test names are better sources than handbook prose. The extractor should emit a sorted JSON file that later stages treat as read-only.

The following example is a local OpenAPI fragment used only to demonstrate the freeze. It is not a production service description.

# examples/orders.openapi.yaml
openapi: 3.0.3
info:
  title: Orders
  version: 0.0.0
paths:
  /v1/orders:
    post:
      operationId: createOrder
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/OrderCreate'
      responses:
        '201':
          description: Created
        '409':
          description: inventory_conflict
components:
  schemas:
    OrderCreate:
      type: object
      required: [sku, quantity]
      properties:
        sku:
          type: string
        quantity:
          type: integer
        priority:
          type: string
          enum: [standard, rush]
Enter fullscreen mode Exit fullscreen mode

A small Python extractor can walk paths, schema names, status codes, and enum values. Keep the output boring and sorted so diffs stay reviewable.

# tools/extract_identifiers.py
from __future__ import annotations

import json
import pathlib
import sys

try:
    import yaml
except ImportError:
    sys.stderr.write("install pyyaml before running extract_identifiers.py\n")
    raise SystemExit(2)


def collect(doc: dict) -> dict[str, list[str]]:
    paths = sorted((doc.get("paths") or {}).keys())
    methods: list[str] = []
    operations: list[str] = []
    statuses: list[str] = []
    for path, item in (doc.get("paths") or {}).items():
        for method, op in item.items():
            if method.startswith("x-") or not isinstance(op, dict):
                continue
            methods.append(f"{method.upper()} {path}")
            if op.get("operationId"):
                operations.append(str(op["operationId"]))
            for code in (op.get("responses") or {}):
                statuses.append(str(code))
    schemas = sorted((doc.get("components") or {}).get("schemas") or {})
    enums: list[str] = []
    for name, schema in ((doc.get("components") or {}).get("schemas") or {}).items():
        for field, spec in (schema.get("properties") or {}).items():
            for value in spec.get("enum") or []:
                enums.append(f"{name}.{field}={value}")
    return {
        "paths": paths,
        "methods": sorted(set(methods)),
        "operations": sorted(set(operations)),
        "statuses": sorted(set(statuses)),
        "schemas": schemas,
        "enums": sorted(set(enums)),
    }


def main() -> int:
    src = pathlib.Path(sys.argv[1])
    dest = pathlib.Path(sys.argv[2])
    doc = yaml.safe_load(src.read_text())
    dest.write_text(json.dumps(collect(doc), indent=2) + "\n")
    return 0


if __name__ == "__main__":
    raise SystemExit(main())
Enter fullscreen mode Exit fullscreen mode

Run the extractor on every contract change so the freeze file is never edited by hand.

python tools/extract_identifiers.py examples/orders.openapi.yaml generated/identifiers.json
Enter fullscreen mode Exit fullscreen mode

Add environment keys and error literals from code in a second extractor if those nouns are public. Keep that second pass equally mechanical. Do not let a model propose extra keys during extraction, because extraction is an inventory, not a design session.

Step 2: Draft only the surface map under the freeze

Feed the model three inputs and no others: the freeze file, the cited source paths, and a hard ban on overlay topics. The prompt should ask for a catalog, not a narrative. Catalogs are easier to lint because each heading can map to one identifier. Narratives hide new nouns inside metaphors and future tense.

Label the next block as a prompt template, not as an executed production run.

Prompt template (unexecuted example)

You draft docs/surface-map.md only.
Use identifiers from generated/identifiers.json and nowhere else.
Each section title must be a method string or a schema name from that file.
Each paragraph must cite examples/orders.openapi.yaml or a test path.
Do not write dates, SLAs, deprecation windows, support hours, or SDK promises.
Do not invent endpoints, fields, status codes, or environment variables.
If a reader question cannot be answered from the freeze file, write TODO-HUMAN.
Enter fullscreen mode Exit fullscreen mode

The TODO-HUMAN token is load-bearing. It is cheaper to leave a hole than to let the model guess a policy. Reviewers should treat remaining TODO-HUMAN markers as merge blockers for the overlay file, not as permission to keep guessing in the generated map.

Teams that need a drafting pass without standing up paid inference can use MonkeyCode free model access for that surface map. Disclosure: This article was prepared as part of MonkeyCode's product outreach. The same extract-and-lint pair can run on MonkeyCode's free server option so the freeze stays in CI beside ordinary tests. Neither step requires the model to choose identifiers; the freeze file already did that work.

Step 3: Lint unknown nouns and obligation verbs

A freeze without a linter is only a suggestion. The linter should fail the build when generated Markdown introduces tokens that are not in the freeze file and are not in a small allowlist of English glue words. It should also fail on obligation verbs that usually smuggle human commitments into a surface map.

# tools/lint_surface_map.py
from __future__ import annotations

import json
import pathlib
import re
import sys

GLUE = {
    "the", "a", "an", "and", "or", "to", "of", "in", "on", "for",
    "is", "are", "optional", "required", "returns", "accepts", "field",
    "request", "response", "http", "json", "schema", "enum", "example",
}
OBLIGATION = re.compile(
    r"\b(guarantee|guarantees|sla|until|deprecated on|we will|must remain|supported through)\b",
    re.I,
)
TOKEN = re.compile(r"[A-Za-z][A-Za-z0-9_/.\\-]*")


def freeze_tokens(blob: dict) -> set[str]:
    tokens: set[str] = set()
    for values in blob.values():
        for item in values:
            tokens.update(t.lower() for t in TOKEN.findall(item))
    return tokens


def main() -> int:
    freeze = json.loads(pathlib.Path(sys.argv[1]).read_text())
    markdown = pathlib.Path(sys.argv[2]).read_text()
    allowed = freeze_tokens(freeze) | GLUE
    failures: list[str] = []
    for lineno, line in enumerate(markdown.splitlines(), start=1):
        if OBLIGATION.search(line):
            failures.append(f"L{lineno}: obligation verb in surface map: {line.strip()}")
        for token in TOKEN.findall(line):
            lowered = token.lower()
            if lowered not in allowed and not lowered.startswith("todo"):
                failures.append(f"L{lineno}: unknown identifier {token}")
    if failures:
        sys.stderr.write("\n".join(failures) + "\n")
        return 1
    return 0


if __name__ == "__main__":
    raise SystemExit(main())
Enter fullscreen mode Exit fullscreen mode

Wire both tools as a single job so a model draft cannot land without the freeze catching up.

# .github/workflows/docs-freeze.yml  (example CI shape)
name: docs-freeze
on: [pull_request]
jobs:
  freeze:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-python@v5
        with:
          python-version: "3.12"
      - run: pip install pyyaml
      - run: python tools/extract_identifiers.py examples/orders.openapi.yaml generated/identifiers.json
      - run: python tools/lint_surface_map.py generated/identifiers.json docs/surface-map.md
Enter fullscreen mode Exit fullscreen mode

The obligation list is intentionally short. Expand it only with verbs your reviewers already reject by hand. A long denylist becomes a style argument. A short denylist remains a contract check.

Step 4: Keep dates, support, and compatibility in a human overlay

Create docs/overlay.md with a header that forbids model writes. Put deprecation dates, support hours, incident targets, and SDK windows only there. The surface map may link to overlay headings, but it may not copy their sentences. CODEOWNERS should assign overlay paths to the people who actually own those promises.

# .github/CODEOWNERS  (example)
/docs/overlay.md          @api-owners @support-leads
/docs/surface-map.md      @api-owners
/generated/identifiers.json @api-owners
Enter fullscreen mode Exit fullscreen mode

Reviewers then answer three questions in order. First, did the freeze file change with the contract. Second, did the surface map lint clean. Third, did any leftover TODO-HUMAN require an overlay edit by a named owner. That order prevents a fluent draft from hiding a policy change inside an endpoint paragraph.

Limitations and who should not use this approach

The freeze does not make generated documentation complete. It only makes missing policy visible. Tutorials that teach judgment, incident language, and pricing still need human authors. Legal notices, security advisories, and availability claims should never pass through the drafting model, even with a freeze file present.

Teams without an OpenAPI file, a schema, or tests should not adopt this pipeline yet. There is nothing honest to freeze, and the linter will only encode folklore. Teams that want the model to invent product names or roadmap copy should use a different process entirely. This workflow is for API surface maps that must survive a contract diff.

The glue-word allowlist will also over-flag domain English in long guides. That is a signal to keep the generated artifact short. If a page needs metaphor, move that page to human writing instead of growing the allowlist until the freeze is meaningless.

A minimal trial shape

Pick one service, one OpenAPI file, and one generated map. Run extract on every contract change, draft only under the freeze, and fail CI on unknown identifiers. Leave dates and guarantees in the overlay until a named owner writes them. The control is the frozen noun set, not a longer prompt, and the build should prove that split on each pull request.

Top comments (0)