DEV Community

Avery Lin
Avery Lin

Posted on

Descriptive Drafts, Normative Stubs: A Claim-Type Contract for Generated Docs

Generated documentation stays useful when models draft only descriptive claims and humans own every normative promise. A claim-type contract makes that split machine-checkable before any model writes a paragraph of documentation. Teams that skip the split watch product guarantees drift into model-authored paragraphs that nobody signed. The rest of this article specifies a YAML contract, a stub generator, and a linter that rejects forbidden claim types.

The failure mode is not empty pages; it is confident language that reads like a contract with users. A model can list flags from an argparse tree and still invent a never-break sentence beside an otherwise mechanical table. Reviewers then treat the whole section as derived output and miss the promise that leaked in. Separating claim types before generation turns that leak into a build failure instead of a late editorial chase.

Heading labels do not stop mixed sentences

Classifying a heading as human-owned still leaves mixed sentences inside an otherwise mechanical section. A configuration heading can hold a flag table that is fully derived and a retention promise that is not. The contract below therefore labels claim types per section and forbids mixed types inside one block. If a section needs both a derived table and a promise, the outline splits those blocks before drafting starts.

This workflow is about the epistemic class of each block, not about who owns a file on disk. File owners still matter for review routing, but they do not tell a generator which sentences it is allowed to invent. A page can be owned by docs engineering and still contain a compatibility promise that only product can sign. Keep those concerns in different files so a linter can fail the right one.

Four claim types the generator must distinguish

Treat every documentation block as exactly one claim type before a model is allowed to see it. Descriptive claims restate facts that a parser can extract from the repository without product judgment. Procedural claims narrate a path that already exists in code, tests, or a recorded runbook. Normative and promissory claims assert rules or future behavior that only a named human can stand behind.

The scripts later in this article are a proposed, unexecuted reference implementation. They are meant for local experiments and continuous-integration sketches, not as evidence of a production deployment. Replace the sample module with your own package before you treat any hash as meaningful. Do not paste secrets, tokens, or customer text into the fact pack or into any prompt.

Decision table

Claim type Source of truth Model may Human must Merge gate
descriptive AST, OpenAPI, config schema, --help Draft from a fact pack only Confirm extraction coverage Fact hash matches the source blob
procedural Call graph, tests, a checked runbook Draft steps from those artifacts Walk the path once before merge Optional command dry-run
normative Policy, RFC, product brief None; emit a stub Write the rule in their own words Stub removed plus a named owner
promissory Legal, SLA, security review None; emit a stub Write the promise with a date Stub removed plus dated owner

Read the table as a generator contract rather than as a style guide for writers. Descriptive and procedural blocks may enter a model prompt after facts are extracted. Normative and promissory blocks must never enter that prompt, even as examples of tone. If a writer needs both a flag table and a support promise, those become two sections with two claim types.

Proposed repository layout

Keep the contract beside the document, not inside the model instructions. A sidecar YAML file is easier to diff than a long system prompt, and it can fail CI without parsing prose. The generated Markdown then carries matching HTML comments so the linter can see section boundaries without a full document AST.

docs/
  api.md                 # generated descriptive blocks + human stubs
  api.claims.yaml        # claim-type contract
  api.facts.json         # extracted, hash-bound facts
scripts/
  extract_facts.py
  emit_stubs.py
  lint_claims.py
Enter fullscreen mode Exit fullscreen mode

The YAML below is a sample contract for a tiny command-line tool. It is not taken from a live product surface, and the module name is a placeholder. Adjust section identifiers to match your heading scheme before you wire the linter into a pipeline.

# docs/api.claims.yaml
document: docs/api.md
fact_pack: docs/api.facts.json
sections:
  - id: overview-flags
    heading: "Command flags"
    claim_type: descriptive
    model_may: draft
    fact_keys: ["flags"]
  - id: install-steps
    heading: "Install from a checkout"
    claim_type: procedural
    model_may: draft
    fact_keys: ["install_commands"]
  - id: support-window
    heading: "Supported versions"
    claim_type: promissory
    model_may: none
    stub_reason: "Support windows are a product commitment, not a parser output."
  - id: auth-rule
    heading: "Authentication requirement"
    claim_type: normative
    model_may: none
    stub_reason: "Must/should rules need a named policy owner."
forbidden_in_model_sections:
  - "guarantee"
  - "we promise"
  - "always"
  - "never break"
  - "SLA"
  - "certified"
  - "backward compatible"
Enter fullscreen mode Exit fullscreen mode

Workflow

1. Freeze the contract before any prompt is built

Write api.claims.yaml in the same change that adds or revises the outline. Reviewers should argue about claim types while the page is still a list of headings, not after a model has filled them with fluent paragraphs. A promissory heading that sneaks through this step will later look like ordinary documentation, which is exactly the leak this gate exists to catch. Merge the contract first when the outline is contested; do not generate prose against an unsigned map.

2. Extract descriptive facts as data, not as sentences

Parse source artifacts into a JSON fact pack with stable keys and a source hash. The generator should receive flag names, types, and defaults rather than a request to explain the tool helpfully. Helpful prose is where promissory language usually appears, because models complete the surrounding social script of product copy. Keep the pack boring on purpose.

# scripts/extract_facts.py  — proposed reference, not a measured benchmark
from __future__ import annotations

import ast
import hashlib
import json
from pathlib import Path


def hash_bytes(data: bytes) -> str:
    return hashlib.sha256(data).hexdigest()


def flags_from_add_argument(module_path: Path) -> dict:
    source = module_path.read_text(encoding="utf-8")
    tree = ast.parse(source)
    flags = []
    for node in ast.walk(tree):
        if not isinstance(node, ast.Call):
            continue
        func = node.func
        name = getattr(func, "attr", None) or getattr(func, "id", None)
        if name != "add_argument":
            continue
        opts = []
        for arg in node.args:
            if isinstance(arg, ast.Constant) and isinstance(arg.value, str):
                opts.append(arg.value)
        default = None
        for kw in node.keywords:
            if kw.arg == "default" and isinstance(kw.value, ast.Constant):
                default = kw.value.value
        if opts:
            flags.append({"options": opts, "default": default})
    return {
        "source": str(module_path),
        "source_sha256": hash_bytes(source.encode("utf-8")),
        "flags": flags,
    }


if __name__ == "__main__":
    pack = flags_from_add_argument(Path("src/cli.py"))
    Path("docs/api.facts.json").write_text(json.dumps(pack, indent=2) + "\n")
Enter fullscreen mode Exit fullscreen mode

Run the extractor in CI against the same revision that builds the document. If src/cli.py changes and the documented flag list does not, the later lint step should fail on the hash, not on wording taste. That failure is cheaper than debating whether a model paraphrased a default value incorrectly.

3. Emit stubs for every block the model must not touch

Human-owned sections should exist as explicit negative space, not as omitted headings. An omitted heading gets filled during a later catch-up generation because the outline looks incomplete. A stub with a reason string tells both the model runner and the reviewer that emptiness is the correct state until a person writes the claim.

# scripts/emit_stubs.py  — proposed reference
from __future__ import annotations

import json
from pathlib import Path

import yaml

STUB = """## {heading}
<!-- CLAIM id={id} type={claim_type} model_may={model_may} -->
<!-- HUMAN_OWN reason="{reason}" -->
<!-- STUB: do not generate prose in this section -->

"""

DESCRIPTIVE = """## {heading}
<!-- CLAIM id={id} type={claim_type} model_may={model_may} -->
{body}

"""


def flag_table(facts: dict) -> str:
    rows = ["| Option | Default |", "| --- | --- |"]
    for item in facts.get("flags", []):
        options = ", ".join(item["options"])
        rows.append(f"| `{options}` | `{item['default']!r}` |")
    return "\n".join(rows)


def main() -> None:
    contract = yaml.safe_load(Path("docs/api.claims.yaml").read_text())
    facts = json.loads(Path(contract["fact_pack"]).read_text())
    parts = [
        "<!-- generated descriptive blocks; human stubs are intentional -->\n"
    ]
    for section in contract["sections"]:
        if section["model_may"] == "none":
            parts.append(
                STUB.format(
                    heading=section["heading"],
                    id=section["id"],
                    claim_type=section["claim_type"],
                    model_may=section["model_may"],
                    reason=section["stub_reason"],
                )
            )
            continue
        body = flag_table(facts) if "flags" in section.get("fact_keys", []) else ""
        parts.append(
            DESCRIPTIVE.format(
                heading=section["heading"],
                id=section["id"],
                claim_type=section["claim_type"],
                model_may=section["model_may"],
                body=body or "_No derived facts for this section._",
            )
        )
    Path(contract["document"]).write_text("".join(parts))


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

The first pass can skip the model entirely and still produce a reviewable page. Derived tables appear where facts exist, and promissory headings appear as stubs with reasons. That page is already more honest than a fully generated draft that quietly answers questions the repository cannot answer.

4. Draft only sections the contract marks as allowed

If prose smoothing is required, send the model the fact pack, the heading, and the claim type, and nothing else from the human stubs. Descriptive sections sometimes still need prose smoothing after a fact pack is extracted from source. That drafting job can run on MonkeyCode's free model access and free server option when a team wants a zero-invoice pass. Disclosure: This article was prepared as part of MonkeyCode's product outreach.

The job should receive only the fact pack and the section contract so promissory stubs never enter the prompt. This article does not name models, quotas, hardware, duration, or benchmarks beyond those two operator-supplied availability claims. A typical allowed prompt is a labeled request to turn a JSON flag list into a short table caption, not a request to write the product page. If the runner cannot strip human stubs from context, do not call the model; keep the table output from step three.

# proposed local runner, unexecuted
python scripts/extract_facts.py
python scripts/emit_stubs.py
# only then, and only for sections with model_may: draft,
# send docs/api.facts.json plus the single heading to the drafting job
python scripts/lint_claims.py
Enter fullscreen mode Exit fullscreen mode

5. Lint claim types, stub integrity, and forbidden verbs

The linter should not try to understand documentation quality. It should verify three mechanical properties that humans are bad at checking after a fluent draft appears. First, every contracted section exists with the expected CLAIM comment. Second, model_may: none sections still contain the stub marker and do not contain extra paragraphs. Third, model-allowed sections do not contain the forbidden verb list from the contract.

# scripts/lint_claims.py  — proposed reference
from __future__ import annotations

import re
import sys
from pathlib import Path

import yaml

CLAIM_RE = re.compile(
    r"<!-- CLAIM id=(?P<id>\S+) type=(?P<type>\S+) model_may=(?P<may>\S+) -->"
)
STUB_RE = re.compile(r"<!-- STUB: do not generate prose in this section -->")


def section_bodies(markdown: str) -> dict[str, str]:
    matches = list(CLAIM_RE.finditer(markdown))
    bodies = {}
    for index, match in enumerate(matches):
        end = matches[index + 1].start() if index + 1 < len(matches) else len(markdown)
        bodies[match.group("id")] = markdown[match.end() : end]
    return bodies


def main() -> int:
    contract = yaml.safe_load(Path("docs/api.claims.yaml").read_text())
    text = Path(contract["document"]).read_text(encoding="utf-8")
    bodies = section_bodies(text)
    errors: list[str] = []
    for section in contract["sections"]:
        body = bodies.get(section["id"])
        if body is None:
            errors.append(f"missing section {section['id']}")
            continue
        if section["model_may"] == "none":
            if not STUB_RE.search(body):
                errors.append(f"{section['id']} lost its stub marker")
            prose = STUB_RE.sub("", body)
            prose = re.sub(r"<!--.*?-->", "", prose, flags=re.S).strip()
            if prose:
                errors.append(f"{section['id']} contains model or human prose before ownership")
            continue
        lowered = body.lower()
        for token in contract["forbidden_in_model_sections"]:
            if token.lower() in lowered:
                errors.append(f"{section['id']} contains forbidden token {token!r}")
    for line in errors:
        print(line)
    return 1 if errors else 0


if __name__ == "__main__":
    sys.exit(main())
Enter fullscreen mode Exit fullscreen mode

Wire the three scripts as a pre-merge job so a regenerated page cannot ship a filled promissory section by accident. The forbidden-token list will not catch every hedge, and it will false-positive on quoted error strings that contain the word always. Maintain the list as an allow-fail discussion in review, but keep the stub check strict, because stub removal is the actual ownership event.

6. Require a named owner before a stub may disappear

When a human writes the support window or the authentication rule, they should delete the stub comment in the same commit that adds the prose. Record the owner and date in the contract or in a neighboring api.owners.yaml file, not in a chat transcript. The linter can then allow prose in that section only if the stub is gone and the owner field is non-empty. That is a weaker control than legal review, and it should be described that way in the pipeline docs.

Limitations

A four-type taxonomy collapses many real sentences into coarse buckets, and some how-to steps hide policy. Token lists do not detect a polite paraphrase of a guarantee, which means a determined model still needs a human reader on descriptive sections. Hashing the fact pack does not prove that the extractor understood argparse semantics, only that the source file did not change silently. None of these controls replace counsel, security review, or a product manager for customer-facing promises.

The approach also assumes the outline can be split when claim types mix. Brownfield pages that intertwine flag tables with support promises will need a manual rewrite before the linter becomes useful. Until that split exists, running the generator will either over-stub the page or under-protect the promises. Do not treat a red lint as proof that the remaining green sections are true.

Who should not use this

Skip this workflow when the document itself is the contract, including security advisories, clinical labeling, financial disclosures, and anything a customer would quote in a dispute. Those pages need a human author from the first outline, not a stub that a busy reviewer might accept because the rest of the file looks generated. Also skip it when no parser can produce a fact pack, because the model would then draft descriptive sections from guesses. In that case, write the page without a generator and keep the claim-type labels as review comments only.

If you already render reference pages from OpenAPI or --help, add the claim-type contract beside that renderer instead of replacing it. The valuable change is the stub boundary around promises, not a new drafting vendor in the inner loop.

Top comments (0)