DEV Community

Avery Lin
Avery Lin

Posted on

Assign Section Roles Before Generating Docs, Then Lint the Markdown

Generated API documentation stays mergeable when every heading has an explicit owner before any model runs. Models may restate enumerated facts drawn from schemas, recorded fixtures, and command scripts already stored in git. Humans must retain recommendations, guarantees, support policy, and any sentence that binds the product to a reader. A YAML role manifest plus a path-aware linter enforces that split more consistently than prompt text alone.

The workflow below is a proposed pattern for repositories that already version OpenAPI documents, HTTP fixtures, and executable task files. It treats model output as untrusted draft text until both the linter and a named reviewer accept the change. Teams without those sources should not generate public reference pages from a chat window and hope the tone stays honest. The rest of this article implements the split as files, commands, and a fail-closed checker rather than as style advice.

A decision table for draftable versus human-owned sections

Assign roles at heading granularity because many reference files mix mechanical tables with advisory paragraphs in the same document. Mechanical text can be regenerated when a schema changes and then compared with git as a structured diff. Promissory text changes what a customer may reasonably believe about support, safety, or fitness for production use. The table is a starting matrix for API documentation and is not a substitute for legal or security review.

Heading role Model may draft Human must own Allowed sources
Path and method catalog Yes, as a restatement Identifier spelling after review OpenAPI paths
Field names and types Yes, compiled rows Prose that explains product meaning Schema files
Copy-paste curl from fixtures Yes, formatting only Labels such as canonical or recommended Recorded HTTP fixtures
Error code enumerations Yes, code plus literal Remediation that implies a support clock Source enums
Local run commands Yes, copied from task files Supported platforms and version ranges Makefile, scripts
Getting-started advice No Yes Product owner
Support, retention, billing No Yes Ops and legal
Security reporting No Yes Security owner
Motivation and audience No Yes Human narrative

Copy-paste examples are draftable only when they are formatted restatements of recorded fixtures, including status codes and headers. The moment an example is labeled recommended, canonical, or production-ready, ownership moves to a human reviewer with product context. Error catalogs are similar: enumerations and literal messages can be compiled, while remediation timelines cannot be invented. Narrative sections such as motivation, audience, and why the product exists have no safe source file for a model to restate.

Step 1. Commit a section-role manifest before any draft runs

Commit docs/doc-roles.yaml before anyone runs a draft job, including experiments on a laptop or a shared worker. Reviewers should be able to read the manifest in one sitting and map every path to a real documentation file. Headings that appear in neither list must fail the linter, because missing roles are how advisory prose sneaks into generated files. Keep forbidden phrases in the same file so policy changes travel with the repository instead of living in a chat snippet.

version: 1
model_owned:
  - path: docs/reference/endpoints.md
    headings:
      - "Endpoint catalog"
      - "Path parameters"
      - "Example request"
    sources:
      - openapi/openapi.yaml
      - fixtures/http/*.json
  - path: docs/reference/errors.md
    headings:
      - "Error code table"
    sources:
      - src/errors/codes.go
human_owned:
  - path: docs/getting-started.md
    headings: ["*"]
  - path: docs/support.md
    headings: ["*"]
  - path: docs/security.md
    headings: ["*"]
  - path: docs/reference/endpoints.md
    headings:
      - "When to use this endpoint"
      - "Production guidance"
forbidden_phrases_in_model_owned:
  - "we guarantee"
  - "you should"
  - "recommended for production"
  - "production-ready"
  - "we recommend"
  - "our commitment"
  - "SLA"
  - "contact support"
  - "will never"
  - "always available"
Enter fullscreen mode Exit fullscreen mode

The manifest is deliberately boring because boring contracts are easier to test than elaborate prompt preambles that drift by the week. Mixed files need both a model_owned heading list and a human_owned heading list for the same path, as shown above. Fully human-owned paths use a wildcard heading so a draft job cannot create those files even with a matching filename. If you later add a new reference page, add a manifest row in the same pull request that introduces the page.

Step 2. Build a staging pack from cited sources only

Build a staging directory that contains only the source files cited by the manifest for the pages you intend to draft. Chat transcripts, prior model dumps, and unpublished roadmap notes do not belong in that directory under any routine workflow. A small shell script is enough to copy the cited artifacts, extract allowed headings, and record which markdown paths will be touched. Recording touched paths matters because the linter should not reinterpret files the draft job never opened.

#!/usr/bin/env bash
set -euo pipefail
STAGING="${1:-.doc-draft-pack}"
rm -rf "$STAGING"
mkdir -p "$STAGING/sources" "$STAGING/out"

cp docs/doc-roles.yaml "$STAGING/"
cp openapi/openapi.yaml "$STAGING/sources/"
cp fixtures/http/*.json "$STAGING/sources/" 2>/dev/null || true
cp src/errors/codes.go "$STAGING/sources/" 2>/dev/null || true

python3 tools/extract_headings.py docs/reference/endpoints.md \
  --only "Endpoint catalog,Path parameters,Example request" \
  > "$STAGING/out/endpoints.model.md"

printf '%s\n' docs/reference/endpoints.md docs/reference/errors.md \
  > "$STAGING/touched.txt"

echo "Pack written to $STAGING"
Enter fullscreen mode Exit fullscreen mode

The extractor is a proposed helper, not an existing product dependency, and it should live beside the linter in tools/.

#!/usr/bin/env python3
"""Write only the named headings from a markdown file to stdout."""
from __future__ import annotations

import argparse
import pathlib
import re
import sys

HEADING_RE = re.compile(r"^(#{1,6})\s+(.*\S)\s*$")


def main() -> int:
    parser = argparse.ArgumentParser()
    parser.add_argument("markdown", type=pathlib.Path)
    parser.add_argument("--only", required=True)
    args = parser.parse_args()
    wanted = {part.strip() for part in args.only.split(",") if part.strip()}

    keep = False
    for line in args.markdown.read_text(encoding="utf-8").splitlines(keepends=True):
        match = HEADING_RE.match(line.rstrip("\n"))
        if match:
            keep = match.group(2).strip() in wanted
        if keep:
            sys.stdout.write(line)
    return 0


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

The extract step prevents a model from rewriting adjacent human-owned headings that already live in the same markdown file. Mixed ownership inside one file is common on endpoint pages, where a table sits beside a paragraph titled production guidance. If extraction yields an empty model section, stop the job and treat the gap as a missing source rather than as creative space. Label the staging output as a draft in the filename or in a header comment so reviewers never confuse it with signed documentation.

Step 3. Draft model-owned headings on a worker that cannot merge

Run the draft on a worker that has no permission to merge, tag, or publish the documentation site from that same session. The worker should receive the staging pack only, which keeps customer data, private incident notes, and credentialed URLs out of the prompt. Disclosure: This article was prepared as part of MonkeyCode's product outreach. MonkeyCode is an open-source project with free model access and a free server option that can run this draft job against the staging pack.

The linter, the manifest, and the merge decision remain in your repository regardless of where that draft text was produced. Feed the model a compiler-like instruction that names allowed headings, required citations, and the UNRESOLVED stop condition. Fluent continuation after a missing source is a failure mode, not a convenience, and the job should exit non-zero.

Restate only the headings listed under model_owned for this path.
Cite a source file for every table row and every example block.
Do not write headings listed under human_owned.
Do not add advice, guarantees, platform support, or support policy.
If a source is missing, write UNRESOLVED and stop that section.
Enter fullscreen mode Exit fullscreen mode

If the model writes UNRESOLVED, leave the heading empty in the working tree and fail the draft job before any commit. Empty headings are cheaper to review than confident paragraphs that cannot point at a schema, fixture, or script. Do not ask the same worker to fill in something reasonable after that failure, because that request reopens promissory text.

Step 4. Lint headings, paths, and promissory phrases in CI

The checker below is a proposed, runnable starting point that understands headings and substring phrases rather than full document semantics. It will miss paraphrases of forbidden claims, which is expected and is why a human still reads every model-owned section. Install PyYAML in the CI image, then invoke the script with the repository root and an optional list of touched paths. Fail closed when a generated path is absent from the manifest or when a human-owned heading appears in the draft output.

#!/usr/bin/env python3
"""Fail CI when generated markdown crosses section roles."""
from __future__ import annotations

import argparse
import pathlib
import re
import sys

import yaml

HEADING_RE = re.compile(r"^(#{1,6})\s+(.*\S)\s*$")


def parse_args() -> argparse.Namespace:
    parser = argparse.ArgumentParser(description=__doc__)
    parser.add_argument("--root", type=pathlib.Path, default=pathlib.Path("."))
    parser.add_argument("--roles", type=pathlib.Path, default=None)
    parser.add_argument("--touched-file", type=pathlib.Path, default=None)
    return parser.parse_args()


def split_sections(text: str) -> list[tuple[str, str]]:
    sections: list[tuple[str, str]] = []
    current = "__preamble__"
    chunks: list[str] = []
    for line in text.splitlines(keepends=True):
        match = HEADING_RE.match(line.rstrip("\n"))
        if match:
            sections.append((current, "".join(chunks)))
            current = match.group(2).strip()
            chunks = [line]
        else:
            chunks.append(line)
    sections.append((current, "".join(chunks)))
    return sections


def index_roles(items: list) -> dict[str, list[str]]:
    mapping: dict[str, list[str]] = {}
    for item in items or []:
        mapping[item["path"]] = list(item.get("headings") or [])
    return mapping


def lint_text(rel: str, text: str, roles: dict) -> list[str]:
    errors: list[str] = []
    model_map = index_roles(roles.get("model_owned"))
    human_map = index_roles(roles.get("human_owned"))
    forbidden = list(roles.get("forbidden_phrases_in_model_owned") or [])

    if rel not in model_map and rel not in human_map:
        return [f"{rel}: path is not listed in doc-roles.yaml"]

    human_headings = human_map.get(rel) or []
    if human_headings == ["*"]:
        return [f"{rel}: path is fully human-owned and cannot be generated"]

    allowed = set(model_map.get(rel) or [])
    blocked = set(human_headings)

    for heading, body in split_sections(text):
        if heading == "__preamble__":
            continue
        if heading in blocked:
            errors.append(f"{rel}: human-owned heading was generated: {heading}")
            continue
        if allowed and heading not in allowed:
            errors.append(f"{rel}: heading is not declared model_owned: {heading}")
            continue
        lowered = body.lower()
        for phrase in forbidden:
            if phrase.lower() in lowered:
                errors.append(
                    f"{rel} #{heading}: forbidden phrase in model-owned section: {phrase}"
                )
    return errors


def main() -> int:
    args = parse_args()
    root = args.root.resolve()
    roles_path = args.roles or (root / "docs" / "doc-roles.yaml")
    roles = yaml.safe_load(roles_path.read_text(encoding="utf-8"))
    if not isinstance(roles, dict):
        sys.stderr.write("doc-roles.yaml must parse to a mapping\n")
        return 2

    touched = None
    if args.touched_file is not None:
        raw = args.touched_file.read_text(encoding="utf-8")
        touched = {line.strip() for line in raw.splitlines() if line.strip()}

    errors: list[str] = []
    for md_path in sorted((root / "docs").rglob("*.md")):
        rel = md_path.relative_to(root).as_posix()
        if touched is not None and rel not in touched:
            continue
        errors.extend(lint_text(rel, md_path.read_text(encoding="utf-8"), roles))

    for item in errors:
        sys.stderr.write(item + "\n")
    return 1 if errors else 0


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

Wire the script into pull request CI so a green prompt session cannot merge without the same gates that protect hand-written files. The workflow file below is an example configuration and should be adapted to your branch protections and required reviewers.

name: lint-doc-roles
on:
  pull_request:
    paths:
      - "docs/**"
      - "tools/lint_doc_roles.py"
jobs:
  lint:
    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/lint_doc_roles.py --root . --touched-file .doc-draft-pack/touched.txt
Enter fullscreen mode Exit fullscreen mode

Add a fixture that expects a non-zero exit when a model-owned file contains the phrase recommended for production. Keep that fixture in tools/testdata so a future change to forbidden phrases cannot silently weaken the gate. A second fixture should fail when docs/getting-started.md appears in the touched list, because that path is fully human-owned.

# Proposed local checks; run from the repository root.
printf 'docs/getting-started.md\n' > /tmp/touched-human.txt
python tools/lint_doc_roles.py --root . --touched-file /tmp/touched-human.txt
echo "human-owned path exit: $?"

mkdir -p /tmp/doc-role-demo/docs/reference tools /tmp/doc-role-demo/tools
cp docs/doc-roles.yaml /tmp/doc-role-demo/docs/
printf '%s\n' '# Example request' 'This sample is recommended for production.' \
  > /tmp/doc-role-demo/docs/reference/endpoints.md
printf 'docs/reference/endpoints.md\n' > /tmp/touched-phrase.txt
python tools/lint_doc_roles.py --root /tmp/doc-role-demo --roles /tmp/doc-role-demo/docs/doc-roles.yaml --touched-file /tmp/touched-phrase.txt
echo "forbidden phrase exit: $?"
Enter fullscreen mode Exit fullscreen mode

Step 5. Require a human signature on residual risk

Require a human reviewer on every documentation pull request, including those whose mechanical tables look obviously correct in the diff. Human-owned files need an explicit sign-off from the role named in your review policy, such as product, security, or support. Model-owned files still need a skim for hallucinated fields, invented status codes, and citations that do not match the pack. If the linter and the reviewer disagree, the reviewer wins, and the manifest should be updated in the same change.

Limitations and who should not use this workflow

Heading matching is brittle when editors rephrase titles, so treat heading strings as identifiers and rename them only with a manifest change. Substring phrase lists will both over-block innocent sentences and under-block clever guarantees that avoid the listed tokens entirely. The workflow does not certify that compiled tables are complete, only that the draft stayed inside the declared section roles. It also does not replace accessibility review, localization, or the editorial pass that makes reference pages readable for newcomers.

Do not use this approach for security advisories, status-page incidents, pricing pages, or any document that creates a customer commitment. Do not use it when you lack OpenAPI files, fixtures, or scripts that can serve as citable sources for the mechanical sections. Do not use it to staff-replace technical writers on narrative guides, tutorials with opinionated paths, or multi-product architecture stories. Regulated industries should keep a qualified human as the document owner of record even when the linter reports a clean run.

Section roles make generation reviewable because they turn a vague trust question into a path, a heading, and a failing exit code. Start with one mixed reference file, add the manifest rows, and prove the linter against a deliberately bad draft before expanding. Keep the model on the mechanical side of the table, and keep every promise in a file that only humans are allowed to touch.

Top comments (0)