DEV Community

Avery Lin
Avery Lin

Posted on

Hash Spec Facts Before Drafting API Documentation

Generated API documentation stays reviewable when every drafted section cites a hashed source, and when humans keep every customer promise. Models can usefully fill tables that already exist in OpenAPI files, test snapshots, or changelog extracts. They should not invent deprecation timelines, support boundaries, or incident narratives that no artifact can later corroborate. The rest of this article describes a source-binding pipeline that enforces that split in continuous integration.

Unbound drafts create documentation debt, not extra coverage

Cheap generation does not make documentation cheaper to operate, because reviewers still must decide which sentences are facts. A parameter table copied from an OpenAPI document is recoverable after a bad draft, since the specification remains authoritative. A sentence that promises a multi-year compatibility window is not recoverable from any file except a human decision record. When those two kinds of text share one markdown file without bindings, review becomes guesswork instead of a hash comparison.

Documentation debt therefore looks like missing provenance rather than missing pages or missing screenshots. Teams that generate an entire guide from one unbounded prompt often cannot say which paragraph should change when the specification changes. That gap appears later as contradictory support answers, not as a failed linter on the documentation pull request. Treating generation as a rendering step over extracted facts keeps the recoverable parts cheap and leaves promises expensive on purpose.

Decision table: derived facts versus human-owned interpretation

The useful split is not “use a model” versus “ban a model.” The useful split is whether a section can be derived from a hashed artifact that already exists in the repository. Use the table as a CI policy, not as a writing-style preference, and deny generation when the source cell is empty.

Section type Typical source Draft policy Human obligation
Parameter tables and status codes OpenAPI or protobuf Model may draft after extract Confirm extractor coverage
Example request bodies Contract tests or fixtures Model may draft from fixtures Reject fields absent from fixtures
Endpoint inventory Routed code or spec paths Model may draft a list Confirm retired paths stay absent
Breaking-change narrative ADR published by an owner Human writes, model may not start it Record date, audience, and owner
Support window and uptime language Policy repository or counsel Human writes Named support or legal owner signs
Incident customer notice Incident ticket plus timeline Human writes On-call owner approves wording
Migration advice (“you should”) Engineering judgment Human writes Owning team is named in the page

A model may summarize an architecture decision record only after a human publishes that record with a stable hash. The summary still remains a draft until the record owner accepts the wording in the published tree. Anything that tells a customer what the product will guarantee is interpretation, even when the surrounding tables are purely derived.

1. Declare a source manifest before any prompt runs

Create a manifest that maps published markdown paths to source artifacts, owners, and draft policies. Keep that manifest in the documentation repository so the binding job can read it on every pull request. The YAML below is a reference sketch for structure, not a claimed production configuration from a live deployment.

# docs/bindings.yaml
version: 1
published_root: docs/published
draft_root: docs/_drafts
sections:
  - id: payments-params
    output: docs/published/payments/parameters.md
    source_kind: openapi
    source_path: specs/payments.yaml
    source_hash: sha256:pending
    draft_policy: derived_only
    owner: api-platform
  - id: payments-examples
    output: docs/published/payments/examples.md
    source_kind: fixture
    source_path: tests/fixtures/payments/
    source_hash: sha256:pending
    draft_policy: derived_only
    owner: api-platform
  - id: payments-support-window
    output: docs/published/payments/support.md
    source_kind: policy
    source_path: policy/support-windows.md
    source_hash: sha256:pending
    draft_policy: human_owns
    owner: support-lead
  - id: payments-migration
    output: docs/published/payments/migration.md
    source_kind: adr
    source_path: docs/adrs/2026-08-payments-v3.md
    source_hash: sha256:pending
    draft_policy: human_owns
    owner: payments-api
Enter fullscreen mode Exit fullscreen mode

The draft_policy field is the only prompt-facing control in this workflow, and it should stay that small. derived_only means the model receives extracted facts plus a forbidden-topic list, never the whole documentation site. human_owns means generation is skipped and the published file must match the last owner-approved blob. Updating source_hash: pending happens after the extractor writes a fact file, not after a person types a prompt.

2. Extract facts with scripts, not with the model

Run extractors first so the model never treats a raw specification as an invitation to paraphrase policy. Hash the extractor output, then store that digest on the matching manifest entry before any draft job starts. The Python sketch below reads a simple OpenAPI subset and writes a sorted JSON fact file that later headers can cite.

# scripts/extract_openapi_facts.py
# Reference sketch: adapt field names to the local specification convention.
import hashlib, json, sys, pathlib

try:
    import yaml
except ImportError:
    sys.exit("install pyyaml before running this extractor")

def load_spec(path):
    with open(path, encoding="utf-8") as handle:
        return yaml.safe_load(handle)

def extract(spec):
    facts = {"paths": []}
    for path, methods in (spec.get("paths") or {}).items():
        for method, op in (methods or {}).items():
            if method.startswith("x-") or not isinstance(op, dict):
                continue
            params = []
            for param in op.get("parameters") or []:
                schema = param.get("schema") or {}
                params.append({
                    "name": param.get("name"),
                    "in": param.get("in"),
                    "required": bool(param.get("required")),
                    "type": schema.get("type"),
                })
            facts["paths"].append({
                "path": path,
                "method": method.upper(),
                "operationId": op.get("operationId"),
                "status_codes": sorted((op.get("responses") or {}).keys()),
                "parameters": params,
            })
    return facts

def write_facts(spec_path, out_path):
    facts = extract(load_spec(spec_path))
    payload = json.dumps(facts, sort_keys=True, indent=2)
    digest = hashlib.sha256(payload.encode("utf-8")).hexdigest()
    pathlib.Path(out_path).parent.mkdir(parents=True, exist_ok=True)
    pathlib.Path(out_path).write_text(payload + "\n", encoding="utf-8")
    print(f"{out_path} sha256:{digest}")

if __name__ == "__main__":
    write_facts(sys.argv[1], sys.argv[2])
Enter fullscreen mode Exit fullscreen mode

A local extraction check only needs two commands, and both should be cheap enough to run on every documentation pull request. If the fact-file digest does not match the digest recorded after the last accepted derived draft, regenerate that draft from the new facts. If the digest matches and only prose in a human_owns file changed, reject the change unless that owner is listed on the pull request.

python scripts/extract_openapi_facts.py specs/payments.yaml docs/_facts/payments.json
sha256sum docs/_facts/payments.json
Enter fullscreen mode Exit fullscreen mode

3. Draft only bound sections, and keep candidates out of the published tree

Feed the model the fact JSON, the section identifier, and a deny list that names human-owned topics. Do not feed the entire documentation site, because adjacent policy pages leak promises into derived tables. A prompt contract can stay this small and still be specific enough for a later header check.

You are drafting section payments-params from docs/_facts/payments.json.
Write markdown tables only. Do not mention support windows, uptime, or migration advice.
Do not invent parameters, status codes, or examples that are absent from the fact file.
If a field is missing in the fact file, write TODO-MISSING-FACT and stop that row.
Output must start with: SOURCE_HASH sha256:<hash-from-facts>
Enter fullscreen mode Exit fullscreen mode

Write model output under docs/_drafts/, never under docs/published/. Promotion is a separate copy step that continuous integration performs only after the binding check passes and the section owner is listed. Teams that need an isolated place to run that draft step can use MonkeyCode's free model access and free server option. Disclosure: This article was prepared as part of MonkeyCode's product outreach, and the product is mentioned only for that isolated draft step. The isolated server is useful because draft files should never share a working tree with published customer pages, and this workflow does not depend on a particular model name, quota, or hardware profile.

Promotion can be a short shell gate rather than another framework. The commands below refuse to copy a draft whose header does not match the current fact-file digest.

DRAFT=docs/_drafts/payments-params.md
FACTS=docs/_facts/payments.json
OUT=docs/published/payments/parameters.md
HASH=$(sha256sum "$FACTS" | awk '{print $1}')
HEAD=$(sed -n '1p' "$DRAFT")
test "$HEAD" = "SOURCE_HASH sha256:$HASH"
mkdir -p "$(dirname "$OUT")"
cp "$DRAFT" "$OUT"
Enter fullscreen mode Exit fullscreen mode

4. Fail the build when a published file is unbound or policy-violating

The gate should stay boring: parse published markdown, require a source header on derived files, and compare digests. Fail closed when a derived file has no header, and fail closed when a human-owned file suddenly grows a model header. The sketch below is a reference checker for that pair of rules.

# scripts/check_doc_bindings.py
# Reference sketch for CI. Extend it with a real manifest loader.
import hashlib, pathlib, re, sys

HEADER = re.compile(r"^SOURCE_HASH sha256:([0-9a-f]{64})\s*$", re.M)
FORBIDDEN = ("uptime credit", "we will support", "guaranteed", "you should migrate")

def file_hash(path):
    data = pathlib.Path(path).read_bytes()
    return hashlib.sha256(data).hexdigest()

def check_pair(markdown_path, facts_path, policy):
    text = pathlib.Path(markdown_path).read_text(encoding="utf-8")
    match = HEADER.search(text)
    if policy == "human_owns":
        if match:
            raise SystemExit(
                f"{markdown_path}: human-owned file must not carry SOURCE_HASH"
            )
        return
    if not match:
        raise SystemExit(f"{markdown_path}: missing SOURCE_HASH header")
    expected = file_hash(facts_path)
    if match.group(1) != expected:
        raise SystemExit(
            f"{markdown_path}: header {match.group(1)} != facts {expected}"
        )
    lowered = text.lower()
    for token in FORBIDDEN:
        if token in lowered:
            raise SystemExit(
                f"{markdown_path}: derived draft contains human-owned language"
            )

if __name__ == "__main__":
    check_pair(sys.argv[1], sys.argv[2], sys.argv[3])
Enter fullscreen mode Exit fullscreen mode

Wire the extractor and the checker as a required status check on paths that can change facts or published prose. After a draft job, inspect git diff -- docs/published; any unexpected hunk means the promotion step leaked into customer-visible files.

# .github/workflows/doc-bindings.yml
name: doc-bindings
on:
  pull_request:
    paths:
      - "docs/**"
      - "specs/**"
      - "tests/fixtures/**"
      - "scripts/**"
jobs:
  bind:
    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 scripts/extract_openapi_facts.py specs/payments.yaml docs/_facts/payments.json
      - run: python scripts/check_doc_bindings.py docs/published/payments/parameters.md docs/_facts/payments.json derived_only
      - run: python scripts/check_doc_bindings.py docs/published/payments/support.md docs/_facts/payments.json human_owns
Enter fullscreen mode Exit fullscreen mode

Limitations, and who should not use this pipeline

Hash binding proves provenance, not correctness of the extractor or of the surrounding product behavior. An OpenAPI file can omit a production-only header, and the model will faithfully omit that header from the table. Token denylists are brittle, and a draft can rephrase a support promise without using any blocked phrase from the checker.

This approach is a poor fit for narrative blogs, design-partner teasers, and any repository that has no specification, fixture, or decision record to hash. It is also a poor fit for legal documents that counsel must draft even when a policy file already exists in git. Small libraries with a single README and no customer support window should not adopt a multi-job pipeline for this split.

Reviewers still need to read human_owns files with the same care used before generation existed. The pipeline only removes derived tables from that reading list when extractors are trustworthy and owners are named. If the team cannot name an owner for support language, stop generating those pages rather than routing the sentences through an isolated draft server.

If an isolated draft workspace would help keep hashed sources away from published pages, evaluate that split against the binding manifest before adding another generation path.

Disclosure: This article was prepared as part of MonkeyCode's product outreach.

Top comments (0)