DEV Community

Avery Lin
Avery Lin

Posted on

Route Each Docs Heading to Shape or Stance Before a Model Drafts

Generated documentation stays honest only when each heading is routed before any model writes a sentence. Shape sections only restate extractable structure taken from schemas, checked fixtures, and declared path lists. Stance sections record commitments a human is willing to defend during review, incidents, and customer support. Mixing those two jobs inside one shared prompt is how invented SLAs enter a public README.

Separate extractable shape from defended stance

Shape covers only facts a parser can recover without interpreting product, legal, or operational policy. Parameter tables, declared status codes, required field names, and fixture-built example payloads belong in that layer. Stance covers any language a customer could quote as a promise after the implementation later changes. Compatibility windows, retry advice, retention language, and explicit security never-claims belong in the human file.

A model that drafts both layers from a blank chat window will fill gaps with plausible guarantees. That failure mode matches agent workflows that keep assuming architecture the repository never actually recorded. The durable fix is not a longer system prompt or a more forceful style guide. The durable fix is a file split that a compiler, linter, and CODEOWNERS file can enforce.

A four-bucket heading taxonomy

Assign every H2 or H3 to one bucket before generation starts, and store that assignment beside the heading. The classifier below treats unknown headings as stance so the model cannot invent a convenient home. When a heading could sit in two buckets, route it to stance until a human splits the outline.

  1. Signature headings restate paths, methods, content types, and field types copied from OpenAPI or protobuf.
  2. Fixture headings format request and response examples derived from checked-in JSON or recorded HTTP transcripts.
  3. Procedure headings list ordered setup steps that a test target or Makefile already executes in the repository.
  4. Stance headings hold deprecation policy, support scope, threat-model language, and compatibility commitments for humans.

Buckets one through three may be drafted by a model that cites those sources and nothing else. Bucket four is handwritten Markdown that the generator must refuse to overwrite during every CI run. Reviewers should reject any generated file that contains a heading whose bucket is still unlabeled. If two buckets seem plausible, keep the heading in stance until the outline is split on purpose.

What a model may draft

The model may restate types, enumerations, required flags, and path templates that exist in the spec. It may format fixture files as fenced examples without adding narrative about observed production behavior. It may turn a Makefile target into a numbered install sequence when every command already exists in the repository. It may not infer default retry counts, timeout budgets, or multi-region failover from those extractable sources.

Keep the draft constrained with a strict output schema rather than free-form Markdown from chat. A useful target object includes heading, bucket, citations, and markdown keys, then rejects extra fields. Reject any object whose citations array is empty, because an uncited paragraph is not shape. Reject any object whose bucket equals stance, even when the prose looks cautious or hedged.

What a human must own

Humans own sentences that use modality about the future behavior or availability of the product. Words such as always, never, guarantee, SLA, backward compatible, and personally identifiable data need a named reviewer. Humans also own absence claims, including statements that the API does not log request bodies today. Saying the handler looks clean in this commit is still not enough to publish that absence.

Humans own versioning policy, including which documented changes count as breaking for published client libraries. If an incident would require a public correction, treat the paragraph as stance without debate. If deleting the paragraph would not change a client's integration, the paragraph is probably shape. When classification remains unclear, leave the heading in the human file and link it from generated shape.

Classify, draft shape, freeze stance, then merge

This sequence is a documented proposal, not a measured multi-team production study with published metrics. Label every generated region as generated until a reviewer accepts the merged Markdown in Git. A green lexicon linter is not legal approval, support approval, or an implicit uptime contract. Keep the outline file small enough that humans can read every heading during an ordinary review.

  1. Freeze the spec and fixtures with a content hash so each draft cites a byte-identical source tree.
  2. Enumerate headings from docs/outline.json rather than allowing the model to invent the section outline.
  3. Run the classifier so each heading receives a bucket plus a list of allowed source globs.
  4. Generate only Signature, Fixture, and Procedure headings into the machine-written docs/generated/shape.md file.
  5. Keep docs/human/stance.md in Git with a CODEOWNERS rule that requires review on every change.
  6. Merge the two files in CI, then lint generated regions against the stance lexicon before publish.
  7. Fail the build when generated Markdown contains an uncited paragraph or a matched stance token.

Artifact: a heading router and stance lexicon linter

The script below is a compact labeled example you can run against a toy OpenAPI document. It does not call a network model, so the ownership boundary stays visible without hidden prompt behavior. Teams can later wrap a model around extract_shape, but the classifier and linter remain the gate. Treat the code as a starting template, and extend parsers only when a source is machine-readable.

#!/usr/bin/env python3
"""Proposal: route doc headings and lint generated shape for stance tokens."""
from __future__ import annotations

import json
import re
import sys
from pathlib import Path

STANCE_TOKENS = re.compile(
    r"\b(always|never|guarantee|guaranteed|sla|backward(?:s)? compatible|"
    r"we will|promise|personally identifiable|no downtime|indefinitely)\b",
    re.I,
)

BUCKETS = {
    "signature": ("openapi", "paths", "components"),
    "fixture": ("fixtures", "examples"),
    "procedure": ("Makefile", "scripts"),
    "stance": (),
}


def load_outline(path: Path) -> list[dict]:
    return json.loads(path.read_text(encoding="utf-8"))


def classify(heading: dict) -> str:
    sources = heading.get("sources", [])
    joined = " ".join(sources).lower()
    if not sources:
        return "stance"
    for bucket, needles in BUCKETS.items():
        if bucket == "stance":
            continue
        if any(needle.lower() in joined for needle in needles):
            return bucket
    return "stance"


def extract_shape(spec: dict, heading: dict) -> str:
    path = heading.get("path")
    method = heading.get("method", "get").lower()
    if not path or path not in spec.get("paths", {}):
        return ""
    operation = spec["paths"][path].get(method, {})
    params = operation.get("parameters", [])
    lines = [f"### {heading['title']}", "", f"`{method.upper()} {path}`", ""]
    if params:
        lines += ["| Name | In | Required | Type |", "| --- | --- | --- | --- |"]
        for param in params:
            schema = param.get("schema", {})
            lines.append(
                f"| {param.get('name', '')} | {param.get('in', '')} | "
                f"{param.get('required', False)} | {schema.get('type', '')} |"
            )
        lines.append("")
    codes = ", ".join(sorted(operation.get("responses", {}).keys()))
    if codes:
        lines += [f"Declared status codes: {codes}.", ""]
    return "\n".join(lines)


def lint_generated(text: str) -> list[str]:
    hits = []
    for index, line in enumerate(text.splitlines(), 1):
        if STANCE_TOKENS.search(line):
            hits.append(f"L{index}: stance token in generated shape: {line.strip()}")
    return hits


def main(argv: list[str]) -> int:
    root = Path(argv[1] if len(argv) > 1 else ".")
    outline = load_outline(root / "docs/outline.json")
    spec = json.loads((root / "openapi.json").read_text(encoding="utf-8"))
    stance = (root / "docs/human/stance.md").read_text(encoding="utf-8")
    parts = ["<!-- generated: shape only; do not edit -->", ""]
    for heading in outline:
        bucket = classify(heading)
        heading["bucket"] = bucket
        if bucket == "stance":
            continue
        block = extract_shape(spec, heading)
        if not block:
            print(f"skip (no extractable shape): {heading['title']}", file=sys.stderr)
            continue
        parts.append(block)
    shape = "\n".join(parts).rstrip() + "\n"
    generated_dir = root / "docs/generated"
    generated_dir.mkdir(parents=True, exist_ok=True)
    (generated_dir / "shape.md").write_text(shape, encoding="utf-8")
    problems = lint_generated(shape)
    if problems:
        print("\n".join(problems), file=sys.stderr)
        return 1
    merged = shape + "\n---\n\n" + stance
    (generated_dir / "API.md").write_text(merged, encoding="utf-8")
    print("wrote docs/generated/shape.md and docs/generated/API.md")
    return 0


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

Pair the script with a tiny outline so the empty sources array can force the stance bucket. Generation therefore cannot emit a compatibility paragraph even if a later model pass is introduced. That empty-sources rule is the ownership line, and it should stay boring on purpose during review. Store the outline beside the spec so reviewers can diff heading routes without opening a chat log.

[
  {
    "title": "Create report",
    "path": "/v1/reports",
    "method": "post",
    "sources": ["openapi.json#/paths/~1v1~1reports"]
  },
  {
    "title": "Compatibility window",
    "sources": []
  }
]
Enter fullscreen mode Exit fullscreen mode
{
  "openapi": "3.1.0",
  "info": {"title": "Reports", "version": "1.0.0"},
  "paths": {
    "/v1/reports": {
      "post": {
        "parameters": [
          {
            "name": "dry_run",
            "in": "query",
            "required": false,
            "schema": {"type": "boolean"}
          }
        ],
        "responses": {
          "201": {"description": "Created"},
          "400": {"description": "Invalid"}
        }
      }
    }
  }
}
Enter fullscreen mode Exit fullscreen mode
mkdir -p docs/human docs/generated
# put outline.json, openapi.json, stance.md, and route_docs.py in place first
python3 route_docs.py .
# fail if generated shape picked up commitment language
if command -v rg >/dev/null; then
  rg -n -i "always|never|guarantee|\\bsla\\b|backward" docs/generated/shape.md && exit 1 || true
fi
Enter fullscreen mode Exit fullscreen mode

The compatibility heading has no sources, so classify() returns stance and extract_shape is never invoked. CI should also search generated shape with the same lexicon the Python linter already applies locally. If either check fires, keep the merge from reaching the branch that publishes developer documentation.

A review table for boundary sentences

Use a short decision table during review when a sentence sits on the boundary between buckets. The table is a process aid, not a statistical model, and it should stay easy to challenge. When a row would allow a model to write the sentence, demand at least one concrete citation path. When a row assigns the sentence to stance, a human name must appear on the pull request.

If the sentence... Bucket Who writes it Required evidence
Restates a path, method, type, or required flag Signature Model may draft OpenAPI or proto pointer
Quotes a checked-in request or response body Fixture Model may draft Fixture path plus hash
Lists commands a Makefile or test already runs Procedure Model may draft Target name in repo
Uses future modality (will, always, never) Stance Human only Named reviewer
Denies a behavior ("does not log bodies") Stance Human only Policy owner
States compatibility, deprecation, or support scope Stance Human only CODEOWNERS on stance file
Has no citation after classification Stance Human only Outline change, not a prompt

Where a coding agent fits without owning promises

A local or hosted coding agent can run route_docs.py and refill Signature tables from openapi.json after spec edits. It can rewrite Fixture sections when example files change, then stop before opening the stance file. Disclosure: This article was prepared as part of MonkeyCode's product outreach. MonkeyCode's free model access and free server option can host that drafting loop while docs/human/stance.md remains a human-owned file.

The agent still must not receive a prompt that asks it to complete missing policy or SLA language. If you add a model step, constrain the tool to rewrite docs/generated/shape.md and no other documentation path. Pass the frozen spec hash and the outline JSON as the only generation context for that step. Keep stance Markdown out of the context window so the model cannot paraphrase it into a looser promise.

Limitations

The lexicon is English-centric and will miss polite guarantees such as the phrase clients can rely on. Table extraction covers OpenAPI parameters and status codes, not callback contracts or streaming response semantics. Heading classification by source-path substring is brittle when repository files use vague or reused names. The script does not prove that a declared status code is actually returned by production traffic.

This approach also does not replace legal review for privacy statements or any marketed uptime language. Privacy statements and uptime language need counsel even when the stance linter remains completely silent. Generated examples can still leak realistic-looking personal data if fixtures were recorded from production systems. Sanitize fixtures before they become documentation, and keep production captures out of the example corpus.

Who should not use this split

Do not use this workflow for narrative product blogs, architecture decision records, or public incident reports. Those genres are stance by default and should remain handwritten under ordinary review without a generator. Do not use it when the spec is a sketch that lags the running service by several weeks. Generating shape from a stale OpenAPI file will document a fiction with unearned structural confidence.

Skip the model loop entirely if the API has a handful of endpoints and one active maintainer. The file split still helps, but an agent adds process without reducing the review load that matters. Teams that cannot require CODEOWNERS on docs/human/stance.md should not generate public docs from a model.

The durable rule is small: regenerate shape from extractable files, and treat undefended commitments as defects. A green CI job means the merge respected the split, not that customers were promised anything true. Keep the stance file shorter than the shape file, because promises should be rarer than signatures. Publish the outline JSON with the docs so readers can see which headings a model was allowed to touch.

Top comments (0)