DEV Community

Avery Lin
Avery Lin

Posted on

Compile Usage Docs From a Frozen Spec Hash, Not From Chat

Usage pages stay honest when they compile from a frozen interface snapshot instead of from a chat transcript. A model may draft field tables, request examples, and path lists only after that snapshot exists in version control. A human still owns promises, severity language, compatibility claims, and anything the schema cannot prove. This article describes a small gate that enforces that split before a documentation pull request can merge.

Chat-first documentation fails for a mechanical reason rather than a stylistic one. The model is asked to explain an API whose contract is still moving, so it fills gaps with confident verbs the OpenAPI file never stated. Reviewers then argue about tone while the real defect is missing provenance: no hash ties the paragraph to a merged schema. A compile-style workflow reverses that order and treats usage Markdown as an output artifact, similar to generated client stubs.

What a frozen spec can actually prove

An OpenAPI document, a JSON Schema, or a captured CLI help snapshot can prove names, types, required flags, and enumerated values. Those facts are stable enough to regenerate when the spec hash changes, and they are cheap to re-check in continuous integration. They are not a product promise. They do not encode incident severity, support hours, data-residency rules, or whether a field will exist in the next minor release.

The useful rule is therefore narrow. If a sentence can be derived from a pointer inside the frozen spec, a model may draft it into a usage page. If a sentence would still be true after the spec file were deleted, a human must write it, because the schema cannot be the source of truth. That second class includes deprecation timelines, partner exceptions, and any claim a customer could quote during an outage.

Decision table: draftable versus human-owned

Keep the split in a checked-in table rather than in a prompt. The table below is a working default for HTTP APIs; adjust the source pointers to match your repository layout. Treat every row as a reviewable policy, not as model instructions.

Doc target Spec pointer Model may draft? Human must own
Path list and HTTP methods paths.* keys Yes, after hash lock Naming collisions across services
Field tables and types components.schemas.* Yes, including required flags Semantic meaning beyond the description string
Request and response examples Schema plus example objects Yes, if examples exist in spec Production payloads with customer data
Error code catalog responses entries that list codes Yes, code and declared schema only Retry guidance, user-facing blame, SLA language
Auth scheme names securitySchemes Yes, scheme type and header names Token storage, rotation, and threat models
Compatibility and versioning none No Sunset dates, breaking-change policy
Support and incident copy none No Severity, status-page wording, legal notices

Rows marked “No” should live under a directory the generator cannot write, such as docs/policy/. Rows marked “Yes” should live under docs/usage/ and should be deleted automatically when the spec hash changes. That deletion is the point: stale usage pages are worse than missing pages because they fail closed in review instead of failing open in production.

Artifact: lockfile, classifier, and a labeled generator stub

The artifact is three small files. First, freeze the spec. Second, reject Markdown that uses human-owned claim language. Third, optionally call a model only for files listed as draftable. The snippets below are a proposed local toolchain; they are not production measurements.

docs/
  usage/                 # regenerable; deleted on hash mismatch
  policy/                # human-owned; generator must not write
  SPEC.lock              # sha256 of the merged interface file
  DOC_MANIFEST.tsv       # path, pointer, draft_allowed, owner
scripts/
  lock_spec.py
  classify_usage.py
  generate_usage.py      # optional; model call stays behind the classifier
openapi.yaml             # merged interface, not a chat paste
Enter fullscreen mode Exit fullscreen mode
# scripts/lock_spec.py — proposed helper, unexecuted in this article
from __future__ import annotations

import hashlib
import pathlib
import sys

SPEC = pathlib.Path("openapi.yaml")
LOCK = pathlib.Path("docs/SPEC.lock")


def sha256(path: pathlib.Path) -> str:
    data = path.read_bytes()
    return hashlib.sha256(data).hexdigest()


def main(argv: list[str]) -> int:
    if not SPEC.exists():
        print("missing openapi.yaml", file=sys.stderr)
        return 2
    digest = sha256(SPEC)
    if argv[1:] == ["--check"]:
        recorded = LOCK.read_text(encoding="utf-8").strip() if LOCK.exists() else ""
        if recorded != digest:
            print(f"spec hash mismatch: {recorded} != {digest}")
            return 1
        print("spec hash ok")
        return 0
    LOCK.parent.mkdir(parents=True, exist_ok=True)
    LOCK.write_text(digest + "\n", encoding="utf-8")
    print(digest)
    return 0


if __name__ == "__main__":
    raise SystemExit(main(sys.argv))
Enter fullscreen mode Exit fullscreen mode
# scripts/classify_usage.py — proposed helper, unexecuted in this article
from __future__ import annotations

import pathlib
import re
import sys

USAGE = pathlib.Path("docs/usage")
POLICY = pathlib.Path("docs/policy")

# Phrases the schema cannot prove. Extend this list in review, not in a prompt.
HUMAN_OWNED = re.compile(
    r"\b(sla|guaranteed|will never|soc\s*2|generally available|"
    r"breaking change|supported until|we promise|99\.\d)%",
    re.I,
)


def fail(message: str) -> int:
    print(message, file=sys.stderr)
    return 1


def main() -> int:
    if POLICY.exists():
        for path in POLICY.rglob("*.md"):
            # Generator output must never appear in policy docs.
            if "AUTO-GENERATED" in path.read_text(encoding="utf-8"):
                return fail(f"generated marker in human-owned file: {path}")
    if not USAGE.exists():
        return 0
    for path in USAGE.rglob("*.md"):
        text = path.read_text(encoding="utf-8")
        hit = HUMAN_OWNED.search(text)
        if hit:
            return fail(f"human-owned claim in usage draft {path}: {hit.group(0)}")
        if "SPEC.lock" not in text and "spec-hash:" not in text.lower():
            return fail(f"usage page missing spec-hash citation: {path}")
    return 0


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

A usage page should cite the lockfile near the top so reviewers can see the compile input without opening CI logs. The following skeleton is a labeled template, not a live document.

<!-- AUTO-GENERATED: delete when docs/SPEC.lock changes -->
<!-- spec-hash: replace-with-lockfile-digest -->

# Checkout session

Field table and examples below are compiled from `openapi.yaml`.
Do not edit this file by hand. Change the spec, update the lock, regenerate.
Enter fullscreen mode Exit fullscreen mode

If you already run documentation jobs on a shared box, MonkeyCode's free model access and free server option can host that regenerate step without introducing a paid inference path. Disclosure: This article was prepared as part of MonkeyCode's product outreach. The model still must not see docs/policy/ and must not receive a prompt that asks for guarantees. Free access does not change the ownership line; it only changes where the compile job runs.

Numbered workflow

  1. Merge the interface change first. Land openapi.yaml or the equivalent schema through the same review you already use for code. Do not generate usage pages from a branch that still disagrees with main.
  2. Refresh docs/SPEC.lock with python scripts/lock_spec.py. Commit the digest in the same pull request as the spec, or in a follow-up that contains no policy Markdown. A lockfile without a spec change is noise; a spec change without a lockfile is a broken compile input.
  3. Delete docs/usage/ when the digest changes. Missing usage pages are a visible CI failure. Leftover pages from the previous hash are a silent accuracy failure, which is worse for support teams.
  4. Run the generator only against manifest rows marked draftable. Pass the spec file, the lock digest, and the allowed heading list. Do not pass incident runbooks, pricing sheets, or prior chat transcripts as extra context.
  5. Run python scripts/classify_usage.py and python scripts/lock_spec.py --check. Fail the job on human-owned phrases, missing hash citations, or generated markers under docs/policy/.
  6. Assign a human reviewer to every policy file touched in the same change set. Usage regeneration can be reviewed for schema fidelity; policy text still needs an owner who can be named in the pull request.

A minimal CI sketch, labeled as an example, looks like the following. Swap the runner image for whatever your org already pins.

# .github/workflows/doc-compile.yml — example only
name: doc-compile
on:
  pull_request:
    paths:
      - openapi.yaml
      - docs/**
      - scripts/**
jobs:
  gate:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - run: python scripts/lock_spec.py --check
      - run: python scripts/classify_usage.py
Enter fullscreen mode Exit fullscreen mode

Local test plan

The gate is only useful if it fails closed on purpose. Use this short plan before you trust it on a default branch. Label the results as local checks, not as published benchmarks.

  1. Copy a valid openapi.yaml and create docs/SPEC.lock from it. Confirm --check exits 0.
  2. Edit one field type in the spec without updating the lock. Confirm --check exits 1 and prints both digests.
  3. Place we promise 99.9% inside docs/usage/checkout.md. Confirm the classifier exits 1 and names the file.
  4. Place AUTO-GENERATED inside docs/policy/support.md. Confirm the classifier exits 1 even if usage pages are clean.
  5. Remove the spec-hash comment from an otherwise valid usage page. Confirm the classifier exits 1.
  6. Restore a clean tree and confirm both scripts exit 0 together. That pair is the merge bar.

If step 3 or step 4 passes, the deny list is too weak or the directory split is not enforced. Fix the classifier before you connect any model. A generator that cannot fail is just another chat window with a Markdown file at the end.

Limitations the hash cannot hide

A spec hash does not prove that descriptions inside the spec are correct. If engineers write jokes or aspirational copy into description fields, the usage page will compile those jokes with high fidelity. Schema review remains a human job; this gate only stops the documentation layer from inventing extra claims.

The classifier is a regular expression, so it will miss paraphrases. “We promise” is easy to catch; “customers can rely on this remaining true” is not. Expand the list from incidents, not from imagined vocabulary, and keep policy files out of the generate path so a missed phrase cannot land in a human-owned directory.

Multi-file specs, vendored third-party fragments, and generated proto stubs need a combined digest. Hashing only openapi.yaml while $ref files move underneath will create false confidence. Fold every resolved input into the lock, or resolve a single bundle before hashing. Until that bundle is deterministic, do not treat the lock as authoritative.

This approach also assumes usage pages may go blank for a short window after a spec change. Organizations that publish docs as the customer contract, rather than as compiled reference, should not delete pages automatically. They need a different process with legal review, and a model should not sit on that path.

Who should not use this gate

Skip the compile split if your “API” is a narrative product surface with no schema, such as a UI-only workflow or a policy blog. There is nothing honest to freeze, so a hash would only decorate the same chat output. Skip it if support copy and usage copy must ship as one page for regulatory reasons; splitting directories would hide required language from the generated half.

Skip it if no owner will maintain the deny list. A stale classifier is worse than no classifier, because it implies that surviving phrases were reviewed. Teams with fewer than two reviewers on documentation changes should keep models away from customer-facing pages entirely and write usage examples by hand from the spec.

The conclusion does not depend on a particular vendor. Freeze the interface, compile only what the interface can prove, and keep promises in files a model cannot write. If you adopt the gate, change the deny list through review the same way you change the spec, and leave chat transcripts out of the compile inputs.

Top comments (0)