DEV Community

Avery Lin
Avery Lin

Posted on

Map Docs Headings to Extract, Draft, and Sign Lanes Before Generating Prose

Generated documentation stays trustworthy when every heading has an explicit owner lane before prose is written. Extractable facts can be compiled from source, schemas, and fixtures without inventing any binding product promises. Draft glue may summarize those facts, yet signed claims about support, security, and pricing still require a human owner. A small provenance checker can block merges when unsigned binding language appears under the wrong heading.

Heading lanes beat free-form generation

Unscoped generation mixes mechanical reference with promises that customers and operators will treat as contracts. That mixture is hard to review because the failure is semantic rather than a simple syntax problem. Reviewers then either rubber-stamp long generated diffs or rewrite the entire page by hand. An ownership matrix attached to headings makes the allowed work visible before a model produces a single sentence.

The matrix does not replace technical review, and it does not claim that generated glue is accurate. It only records who is allowed to write which class of statement in each section. Teams that skip the matrix usually discover the gap when a draft sentence quietly states an SLA, a retention period, or a compatibility guarantee.

Three lanes defined by what can be proven

Assign every heading, including nested headings, to exactly one lane before generation work starts.

  1. Extract. Content must be derived from parser-visible sources such as signatures, OpenAPI paths, or configuration keys. The lane may emit tables, identifier lists, and default literals that a parser already produced. It may not emit rationale, operational advice, or any future-looking language about the product.
  2. Draft. A model may write transitional summaries that restate already extracted facts in ordinary running prose. Every draft block must carry a provenance marker and must not introduce numbers, dates, or obligations missing from extract output.
  3. Sign. A named human must write or explicitly approve the section before it can merge. This lane covers support windows, security properties, deprecation timelines, pricing language, legal notices, and performance figures.

The lanes are mutually exclusive at heading scope and should not be blended inside one section. If a page needs both a parameter table and a compatibility promise, split that page into two headings with separate lanes.

Decision table for common README sections

Heading pattern Default lane Model may draft Human must own Merge blocker
Install commands Extract Command list from Makefile or package manifest Platform support matrix Support claims without a signer
API overview Draft Restatement of extracted endpoints Stability and versioning policy "stable" or "supported" without sign
Configuration Extract Key names and types Secret handling and restart behavior Default secrets or production advice
Examples Draft Happy-path walkthroughs from fixtures Failure modes and data sensitivity Live credentials or customer data
Troubleshooting Draft Symptom restatement from known error codes Root-cause guarantees and workarounds "always" or "never fails" language
Security Sign None Threat model, data classes, disclosure Any generated paragraph
Changelog impact Sign None Breaking-change classification Inferred "non-breaking" labels
Support and SLA Sign None Hours, channels, and remedies Any uptime or response promise

Treat the table as a starting policy rather than a universal standard for every product. Replace rows when local vocabulary differs, but keep the blocker column concrete enough for a script to enforce without debate.

Workflow

1. Inventory headings from the docs tree

Walk Markdown files and record heading text, depth, and source path before anyone drafts prose. The inventory is the input to lane assignment, and it should fail when duplicate titles would make ownership ambiguous across files. Keep this pass read-only so generation cannot hide behind a moving outline.

python3 scripts/inventory_headings.py --docs docs --out build/headings.json
Enter fullscreen mode Exit fullscreen mode

A proposed inventory record looks like the following JSON object and should stay boring.

{
  "path": "docs/readme.md",
  "line": 42,
  "depth": 2,
  "text": "Configuration"
}
Enter fullscreen mode Exit fullscreen mode

2. Assign lanes in a committed YAML matrix

Humans, not models, fill docs/ownership.yaml because that file is policy and must be reviewed like code. Unknown headings must default to sign so newly added sections cannot slip into the draft lane by omission. Commit the matrix beside the docs tree, and require the same reviewers who own release language.

# docs/ownership.yaml
version: 1
default_lane: sign
files:
  docs/readme.md:
    "Install": extract
    "Quick start": draft
    "Configuration": extract
    "Examples": draft
    "Security": sign
    "Support": sign
    "Limitations": sign
Enter fullscreen mode Exit fullscreen mode

3. Compile extractables into a facts sidecar

Emit a facts file from code, not from chat, and keep values limited to identifiers, types, enumerations, and parser-visible literals. Leave units, secret classes, and restart semantics out of this sidecar whenever those judgments require a human. Label the extractor command as proposed until it exists for the actual repository layout.

python3 scripts/extract_facts.py --from src --out build/facts.json
Enter fullscreen mode Exit fullscreen mode

The important constraint is that extract output remains checkable against source, even when the surrounding README later gains draft summaries.

4. Allow draft glue only under draft headings

When a draft model runs, pass only the facts sidecar plus the target heading, and instruct it to refuse new quantities, dates, and obligations. Wrap each generated block with provenance comments so later CI can see the lane without parsing vendor metadata. Keep prompt text and sampling settings out of signed repository policy; the marker is the contract.

<!-- lane:draft provenance:model source:build/facts.json unsigned -->
The service exposes the extractable endpoints listed in the facts sidecar,
and the overview restates those paths without adding a compatibility promise.
<!-- /lane -->
Enter fullscreen mode Exit fullscreen mode

5. Sign binding headings in a separate commit

Require a signed-by value that matches a known reviewer identity for every sign heading in the tree. The signer is asserting that the claims are intended, not that surrounding draft glue is elegant or complete. Reject empty sign sections, because a missing promise is clearer than a generated promise that nobody owned.

<!-- lane:sign signed-by:avery.lin reviewed:2026-09-23 -->
Security reviews treat API keys as secrets. Rotation remains a human-owned
operational procedure and is not inferred from configuration field names.
<!-- /lane -->
Enter fullscreen mode Exit fullscreen mode

6. Fail CI when provenance and language disagree

Run the checker on pull requests and fail closed on missing markers, lane mismatches, and forbidden phrases under draft or extract headings. Also fail sign headings that lack a signed-by field or still carry provenance:model. Print paths and heading text so authors can fix the section without rereading the entire policy file.

python3 scripts/check_doc_ownership.py \
  --matrix docs/ownership.yaml \
  --docs docs \
  --forbidden-file policies/binding-phrases.txt
Enter fullscreen mode Exit fullscreen mode

Proposed checker

The following script is a proposed local gate. It is not a published package and has not been executed against a private corpus in this article. Read it as a reproducible method, then adapt path handling to the repository you actually maintain.

#!/usr/bin/env python3
"""Fail CI when doc headings violate extract/draft/sign ownership."""

from __future__ import annotations

import argparse
import re
import sys
from pathlib import Path

try:
    import yaml
except ImportError:
    yaml = None

HEADING_RE = re.compile(r"^(#{1,6})\s+(.+?)\s*$")
LANE_OPEN_RE = re.compile(
    r"<!--\s*lane:(extract|draft|sign)(?P<attrs>[^>]*)-->",
    re.IGNORECASE,
)
SIGNED_RE = re.compile(r"signed-by:([^\s]+)")

DEFAULT_FORBIDDEN = (
    r"\bSLA\b",
    r"\bguaranteed?\b",
    r"\bsupported until\b",
    r"\bSOC\s*2\b",
    r"\bHIPAA\b",
    r"\buptime\b",
    r"\bnon-breaking\b",
    r"\bforever\b",
    r"\bno downtime\b",
    r"\benterprise support\b",
    r"\bwe will never\b",
    r"\b99\.\d+%\b",
)


def load_matrix(path: Path) -> dict:
    if yaml is None:
        raise SystemExit("PyYAML is required for check_doc_ownership.py")
    data = yaml.safe_load(path.read_text(encoding="utf-8"))
    if not isinstance(data, dict):
        raise SystemExit("ownership matrix must be a mapping")
    return data


def split_sections(text: str) -> list[dict]:
    lines = text.splitlines()
    sections: list[dict] = []
    current = {"text": "(preamble)", "depth": 0, "start": 0, "lines": []}
    for i, line in enumerate(lines):
        match = HEADING_RE.match(line)
        if match:
            current["end"] = i
            sections.append(current)
            current = {
                "text": match.group(2).strip(),
                "depth": len(match.group(1)),
                "start": i,
                "lines": [],
            }
        current["lines"].append(line)
    current["end"] = len(lines)
    sections.append(current)
    return sections


def lane_for(matrix: dict, rel: str, heading: str) -> str:
    default = str(matrix.get("default_lane", "sign")).lower()
    files = matrix.get("files") or {}
    per_file = files.get(rel) or files.get(rel.replace("\\", "/")) or {}
    return str(per_file.get(heading, default)).lower()


def block_lane(body: str) -> str | None:
    open_match = LANE_OPEN_RE.search(body)
    if not open_match:
        return None
    return open_match.group(1).lower()


def check_file(path: Path, rel: str, matrix: dict, forbidden: list[re.Pattern]) -> list[str]:
    errors: list[str] = []
    text = path.read_text(encoding="utf-8")
    for section in split_sections(text):
        heading = section["text"]
        if heading == "(preamble)":
            continue
        expected = lane_for(matrix, rel, heading)
        body = "\n".join(section["lines"][1:])
        found = block_lane(body)
        if found is None:
            errors.append(f"{rel}: heading '{heading}' has no lane marker")
            continue
        if found != expected:
            errors.append(
                f"{rel}: heading '{heading}' marked {found}, matrix requires {expected}"
            )
        if expected in {"draft", "extract"}:
            for pattern in forbidden:
                if pattern.search(body):
                    errors.append(
                        f"{rel}: heading '{heading}' uses binding language in {expected} lane"
                    )
                    break
        if expected == "sign":
            if not SIGNED_RE.search(body):
                errors.append(f"{rel}: sign heading '{heading}' lacks signed-by")
            if "provenance:model" in body.lower():
                errors.append(
                    f"{rel}: sign heading '{heading}' still contains model provenance"
                )
        if expected == "extract" and re.search(r"\bshould\b|\brecommend\b|\bplease\b", body, re.I):
            errors.append(f"{rel}: extract heading '{heading}' contains advisory prose")
    return errors


def main() -> int:
    parser = argparse.ArgumentParser()
    parser.add_argument("--matrix", type=Path, required=True)
    parser.add_argument("--docs", type=Path, required=True)
    parser.add_argument("--forbidden-file", type=Path)
    args = parser.parse_args()
    matrix = load_matrix(args.matrix)
    patterns = [re.compile(p, re.I) for p in DEFAULT_FORBIDDEN]
    if args.forbidden_file and args.forbidden_file.exists():
        extra = [
            line.strip()
            for line in args.forbidden_file.read_text(encoding="utf-8").splitlines()
            if line.strip() and not line.startswith("#")
        ]
        patterns.extend(re.compile(p, re.I) for p in extra)
    errors: list[str] = []
    for path in sorted(args.docs.rglob("*.md")):
        errors.extend(check_file(path, path.as_posix(), matrix, patterns))
    for item in errors:
        print(item, file=sys.stderr)
    if errors:
        print(f"ownership check failed with {len(errors)} issue(s)", file=sys.stderr)
        return 1
    print("ownership check passed")
    return 0


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

A proposed test plan, also unexecuted here, should cover at least the five cases below.

  1. A draft heading that mentions an SLA fails the checker with a binding-language error.
  2. A sign heading without signed-by fails even when the surrounding prose looks complete.
  3. A heading missing from the matrix inherits sign and cannot accept model provenance markers.
  4. An extract heading that contains "should" fails even without forbidden legal or uptime terms.
  5. Matching lane markers with no binding phrases pass and print a single success line.

Where a free draft model belongs

Draft headings are the only place a general writing model should run inside this workflow. Extract headings should stay compiler-like, and sign headings should stay human even when the surrounding page is mostly generated. If draft capacity already exists, confine that capacity to headings labeled draft and keep the matrix plus checker in ordinary CI.

Disclosure: This article was prepared as part of MonkeyCode's product outreach. MonkeyCode offers free model access and a free server option, which can run the draft-lane prompt against the facts sidecar. That placement does not make draft glue signed, and it does not replace the ownership checker on pull requests. Keep secrets, production credentials, and unpublished legal copy off the draft host.

The product mention is optional for the method itself. The matrix, markers, and checker remain useful if the draft step is a local script instead of a hosted model.

Limitations and who should not use this

The checker matches headings by exact text, so renamed titles silently fall back to the default sign lane. That fallback is safer than auto-drafting, but it will surprise authors who expected fuzzy matching across refactored outlines. Forbidden-phrase lists are brittle, because determined prose can imply an SLA without using the word "SLA". Human review of sign headings remains mandatory after the script passes.

Do not use this approach as a substitute for legal review, security review, or regulated labeling of a shipped product. Do not point a draft model at customer data, unpublished vulnerabilities, or billing rules that have not been approved. Teams without a named signer should keep generation off entirely, because an unsigned matrix is only a comment file.

The workflow also assumes English Markdown with ATX headings and one owner per heading text. ReStructuredText, generated HTML, and notebook prose need a different splitter before the same policy can apply without false negatives.

What this method does not claim

Heading ownership does not measure documentation quality, and it does not prove that extracted tables match production behavior today. It only constrains who is allowed to assert which class of statement in a given section. That constraint is enough to stop a common failure mode: a fluent draft that accidentally ships a product promise.

If a docs tree already mixes reference tables with support language, start with the inventory and the YAML matrix on a few pages. Add the checker after those pages have explicit lanes, rather than wrapping an entire site in markers during a single change.

Top comments (0)