DEV Community

Avery Lin
Avery Lin

Posted on

Allowlist Every Path a Docs Generator May Write

Generated reference docs stay trustworthy when a generator may write only restatable files under an allowlisted path. Support windows, uptime figures, and deprecation calendars belong in a second tree that continuous integration refuses to stamp. This article describes a two-directory layout, an OpenAPI table generator, and a linter that fails leaked boundaries. The method remains useful if every drafting product is removed from the workflow.

Mixed endpoint files hide the judgment line

Many teams keep one markdown file per route and let a model fill the entire page. Parameter names recovered from OpenAPI then sit beside sentences that promise multi-year compatibility. Reviewers cannot see which lines a schema can prove and which lines a counsel must sign. A later regenerate overwrites a human promise, or a model invents a calendar that never existed in the spec.

A path allowlist makes that failure visible in git instead of in a subjective prose review. Files under docs/reference/ may be rewritten from OpenAPI on every protected-branch build. Files under docs/policy/ may change only through a human commit that carries no generation stamp. The generator never receives the policy directory as an output argument, so a prompt cannot negotiate extra write access.

Classify fragments before any drafting step starts

Label each intended block as restatable or owned before a model sees the heading. Restatable blocks are recoverable from a schema, a fixture, or a named test. Owned blocks encode calendar risk, money, availability, or a public commitment. The table below is a working classifier for API docs, not a complete documentation ontology.

Fragment Recoverable source Generator may write Human must own
Parameter table OpenAPI parameters Yes Review only
Response field table OpenAPI properties Yes Review only
Status code list OpenAPI responses Yes Review only
Security scheme names OpenAPI securitySchemes Yes Review only
Example body Versioned test fixture Restate after hash check The fixture itself
Token lifetime Rarely a schema field Only if cited in spec Yes if absent
Support end date Never a schema No Yes
Uptime percentage Never a schema No Yes
"No breaking changes" Never a schema No Yes
Rate limit as a guarantee Contract or runbook No Yes
Rate limit copied from spec OpenAPI field or extension Yes, as restatement Yes if not in spec

Treat any date that is not copied from a versioned spec field as owned. Treat first-person commitments such as "we will" and "we guarantee" as owned without exception. Treat numeric availability targets as owned unless a cited runbook already publishes the same figure.

Directory contract and ownership file

Use two roots and a machine-readable allowlist rather than a comment inside a chat prompt. Prompts drift; a YAML file in the same repository can be reviewed like any other interface.

docs/
  reference/          # generator output only
    paths/
    schemas/
  policy/             # humans only
    support.md
    deprecation.md
    sla.md
  ownership.yaml      # write allowlist and forbidden patterns
openapi/
  fragment.yaml       # source of restatable tables
Enter fullscreen mode Exit fullscreen mode

Example docs/ownership.yaml for the linter in the next section:

allow_write:
  - docs/reference/
forbid_write:
  - docs/policy/
require_stamp_under:
  - docs/reference/
forbid_stamp_under:
  - docs/policy/
forbid_patterns:
  - '\bwe (will|guarantee|promise|commit)\b'
  - '\buntil \d{4}-\d{2}-\d{2}\b'
  - '\b99\.\d+%\b'
  - '\bSLA\b'
  - '\bno breaking changes\b'
stamp_prefix: "<!-- generated:"
Enter fullscreen mode Exit fullscreen mode

The YAML is the contract the linter loads on every pull request. If a heading is not restatable, it does not belong under docs/reference/, and the generator must not create a file for it.

Numbered workflow

Follow these steps in order. Skipping the allowlist file makes the later scripts unenforceable, because CI then has no declared write set.

  1. Inventory current API headings and mark each row restatable or owned using the classifier table.
  2. Move owned prose into docs/policy/ in a human commit that does not invoke the generator.
  3. Add docs/ownership.yaml with write allowlists, stamp rules, and forbidden commitment patterns.
  4. Point the generator at OpenAPI only, with an output directory of docs/reference/.
  5. Optionally rephrase a restatable table with a drafting model, then paste only into an allowlisted file.
  6. Run the ownership linter on every pull request that touches docs/ or openapi/.
  7. Fail the build if a stamp appears under policy, or if a reference file matches a forbidden pattern.

Step five is optional because tables can be emitted directly from OpenAPI without a model. When wording still needs a restatement pass, keep the model on the restatable fragment only. A drafting host with free model access and a free server option, including MonkeyCode, can rephrase rows that already exist in the spec. Disclosure: This article was prepared as part of MonkeyCode's product outreach. Do not upload docs/policy/ to that host, and do not grant it a filesystem path outside docs/reference/.

Local OpenAPI fixture and generator

The Python below is a labeled local example, not a production benchmark and not a claim about live traffic. Save the OpenAPI fragment as openapi/fragment.yaml and run the generator from the repository root.

openapi: 3.0.3
info:
  title: Orders fragment
  version: "1.2.0"
paths:
  /orders/{orderId}:
    get:
      operationId: getOrder
      parameters:
        - name: orderId
          in: path
          required: true
          schema:
            type: string
            format: uuid
          description: Identifier of the order resource.
        - name: include
          in: query
          required: false
          schema:
            type: string
            enum: [items, totals]
          description: Optional related resources to embed.
      responses:
        "200":
          description: Order representation.
        "404":
          description: Order identifier was not found.
Enter fullscreen mode Exit fullscreen mode
# generate_reference.py — example generator, not a measured service
from __future__ import annotations

import hashlib
import pathlib
import re
import sys

try:
    import yaml
except ImportError:
    sys.stderr.write("Install pyyaml before running this example.\n")
    sys.exit(2)

ROOT = pathlib.Path(__file__).resolve().parent
SPEC = ROOT / "openapi" / "fragment.yaml"
OUT_DIR = ROOT / "docs" / "reference" / "paths"
OWNERSHIP = ROOT / "docs" / "ownership.yaml"
COMMIT_RE = re.compile(
    r"\bwe (will|guarantee|promise|commit)\b|\buntil \d{4}-\d{2}-\d{2}\b|\bSLA\b",
    re.I,
)


def load_yaml(path: pathlib.Path) -> dict:
    with path.open(encoding="utf-8") as handle:
        return yaml.safe_load(handle)


def stamp_for(payload: str) -> str:
    digest = hashlib.sha256(payload.encode("utf-8")).hexdigest()[:16]
    return f"<!-- generated: true; source: openapi; sha256:{digest} -->\n"


def parameter_table(parameters: list) -> str:
    lines = [
        "| Name | In | Required | Schema | Spec description |",
        "| --- | --- | --- | --- | --- |",
    ]
    for item in parameters:
        schema = item.get("schema") or {}
        schema_label = schema.get("format") or schema.get("type") or ""
        if "enum" in schema:
            schema_label = f"enum:{','.join(schema['enum'])}"
        desc = (item.get("description") or "").replace("\n", " ")
        if COMMIT_RE.search(desc):
            raise ValueError(f"spec description contains a commitment: {desc!r}")
        lines.append(
            f"| {item['name']} | {item['in']} | {item.get('required', False)} | {schema_label} | {desc} |"
        )
    return "\n".join(lines) + "\n"


def main() -> int:
    rules = load_yaml(OWNERSHIP)
    allowed = tuple(rules["allow_write"])
    rel = str(OUT_DIR.relative_to(ROOT)).replace("\\", "/") + "/"
    if not rel.startswith(tuple(allowed)):
        raise SystemExit(f"refusing to write outside allowlist: {rel}")

    spec = load_yaml(SPEC)
    OUT_DIR.mkdir(parents=True, exist_ok=True)
    for path_key, item in spec["paths"].items():
        operation = item["get"]
        table = parameter_table(operation.get("parameters") or [])
        responses = operation.get("responses") or {}
        status_rows = ["| Status | Spec description |", "| --- | --- |"]
        for code, body in responses.items():
            desc = (body.get("description") or "").replace("\n", " ")
            if COMMIT_RE.search(desc):
                raise ValueError(f"response description contains a commitment: {desc!r}")
            status_rows.append(f"| {code} | {desc} |")
        slug = operation["operationId"]
        body = (
            f"# {operation['operationId']}\n\n"
            f"Path: `{path_key}`\n\n"
            f"## Parameters\n\n{table}\n"
            f"## Responses\n\n" + "\n".join(status_rows) + "\n"
        )
        if COMMIT_RE.search(body):
            raise ValueError("generator refused to emit a commitment pattern")
        text = stamp_for(body) + body
        target = OUT_DIR / f"{slug}.md"
        target.write_text(text, encoding="utf-8")
        print(f"wrote {target.relative_to(ROOT)}")
    return 0


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

Run the example with the following commands after docs/ownership.yaml exists. The commands assume a virtual environment that already has PyYAML.

python -m pip install pyyaml
python generate_reference.py
cat docs/reference/paths/getOrder.md
Enter fullscreen mode Exit fullscreen mode

Expected shape of the generated file, including the stamp line the linter will require:

<!-- generated: true; source: openapi; sha256:16-hex-chars -->
# getOrder

Path: `/orders/{orderId}`

## Parameters

| Name | In | Required | Schema | Spec description |
| --- | --- | --- | --- | --- |
| orderId | path | True | uuid | Identifier of the order resource. |
| include | query | False | enum:items,totals | Optional related resources to embed. |
Enter fullscreen mode Exit fullscreen mode

The generator copies descriptions only after a commitment regex check. If an OpenAPI author writes "we guarantee support until 2028-01-01" into a parameter description, the script exits instead of laundering that sentence into reference docs.

Ownership linter for pull requests

The second script is the actual control, because generators are easy to rerun with a wider output path. Save it as check_docs_ownership.py and fail CI when it returns a non-zero status.

# check_docs_ownership.py — example linter, not a hosted policy engine
from __future__ import annotations

import pathlib
import re
import sys

import yaml

ROOT = pathlib.Path(__file__).resolve().parent
RULES = yaml.safe_load((ROOT / "docs" / "ownership.yaml").read_text(encoding="utf-8"))
STAMP = RULES["stamp_prefix"]
PATTERNS = [re.compile(p, re.I) for p in RULES["forbid_patterns"]]


def files_under(prefix: str) -> list[pathlib.Path]:
    base = ROOT / prefix
    if not base.exists():
        return []
    return [p for p in base.rglob("*") if p.is_file()]


def main() -> int:
    errors: list[str] = []
    for path in files_under("docs/policy/"):
        text = path.read_text(encoding="utf-8")
        if STAMP in text:
            errors.append(f"stamp in policy file: {path.relative_to(ROOT)}")
    for path in files_under("docs/reference/"):
        text = path.read_text(encoding="utf-8")
        if STAMP not in text:
            errors.append(f"missing stamp: {path.relative_to(ROOT)}")
        for pattern in PATTERNS:
            if pattern.search(text):
                errors.append(
                    f"commitment pattern {pattern.pattern} in {path.relative_to(ROOT)}"
                )
    for line in errors:
        sys.stderr.write(line + "\n")
    return 1 if errors else 0


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

Wire both scripts in a job that cannot be skipped on documentation-only changes:

# .github/workflows/docs-ownership.yml — example workflow file
name: docs-ownership
on:
  pull_request:
    paths:
      - "docs/**"
      - "openapi/**"
      - "generate_reference.py"
      - "check_docs_ownership.py"
jobs:
  lint:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-python@v5
        with:
          python-version: "3.12"
      - run: python -m pip install pyyaml
      - run: python generate_reference.py
      - run: python check_docs_ownership.py
Enter fullscreen mode Exit fullscreen mode

A useful extra assertion is a git diff --exit-code docs/policy after the generator runs. If that tree changes during a generate step, the allowlist has already failed, and the pull request should not merge.

What this workflow does not prove

Regex ownership checks miss commitments written as nouns, tables, or screenshots. An OpenAPI description field can already contain a promise, so stripping or quarantining description may be safer than copying it. Multi-language sites need a policy tree per locale, because a translated SLA is still an owned artifact. The layout also does not replace legal review of anything published under docs/policy/.

Skip this approach when the product has no machine-readable schema, when marketing pages are the only docs, or when incident copy on a status page must stay entirely human. Skip it when a generator must emit SDK README files that mix install commands with support hours. Those files need a different split than reference tables versus policy prose.

The durable control is the write path, not the model choice. Keep restatable tables under docs/reference/, keep promises under docs/policy/, and fail any build that lets a stamp or a commitment cross that line.

Top comments (0)