DEV Community

Avery Lin
Avery Lin

Posted on

Classify Each Heading Path Before a Model Drafts the Page

Generated documentation stays reviewable when every heading is classified before a model writes a single paragraph. Models may draft procedures, tables, and command lists that a later check can verify against the repository. Humans must own rationale, customer promises, and any sentence that implies a product or legal commitment. The rest of this article turns that split into a heading-path contract and a patch classifier you can run in continuous integration.

File-level ownership hides mixed authorship

A whole-file ownership flag is easy to implement and almost as easy to evade during a busy release week. Generated refresh jobs often rewrite a troubleshooting table that should be cheap to regenerate, then rewrite the adjacent rationale section in the same hunk. Reviewers receive one documentation pull request and cannot tell which headings were allowed to come from a model. The result is not merely stylistic drift; later incidents cannot reconstruct mixed authorship from ordinary git history.

Heading paths already match how reviewers skim a page, so they are the right policy key. Each path such as Runbook / Restart the worker receives one of three states: model_ok, human_only, or hybrid. A classifier then reads a unified diff and maps each changed line to the nearest heading path. The job fails when a model-authored hunk lands on a forbidden path, which keeps the policy mechanical.

A three-state matrix instead of a binary list

Binary allow-or-deny lists collapse the common case where a model may list steps but must not invent the reason those steps exist. Hybrid headings accept model-produced lists, tables, and fenced commands, yet they require a human-signed rationale block that the classifier refuses to treat as model output. Human-only headings reject model hunks entirely, including whitespace-only regenerations that would otherwise look harmless during review. Model-ok headings still pass through a speech-act denylist so a troubleshooting page cannot acquire an accidental availability promise.

The table below is a proposed policy artifact, not a measured production study. Map a real documentation tree against it before you encode the states in continuous integration. Unlisted heading paths should default to human_only until someone deliberately opens them. That default is slower for drafting, and that slowness is the safety property.

Heading kind Typical paths Draft state Model may write Human must own
How-to procedure Install, restart, rollback model_ok Numbered steps, commands, flags Preconditions that imply support scope
Reference tables Flags, env vars, exit codes model_ok Rows copied from schema or code Meaning of deprecated or unsafe fields
Conceptual why Design rationale, tradeoffs human_only Nothing Argument, alternatives, non-goals
Promises SLA, security posture, compliance human_only Nothing Any claim a customer could quote
Mixed runbook Incident steps plus blast radius hybrid Steps and command blocks Impact notes and the signed why

Put the contract beside the tree, not inside generated Markdown

Place draftability.yml next to the docs root so a regeneration job cannot rewrite the policy while it rewrites pages. Paths match from the most specific heading chain to the least specific parent, which lets a runbook share defaults without copying every child. Unlisted paths default to human_only, which keeps brand-new pages out of the model until a reviewer classifies them. The speech_acts list applies only to hunks labeled as model output, so human edits are not punished for quoting a forbidden phrase during an incident write-up.

# docs/draftability.yml
# Proposed contract. Label this file as policy, not as generated output.
version: 1
default_state: human_only
speech_acts:
  - "guarantee"
  - "we promise"
  - "sla"
  - "certified"
  - "fully compliant"
  - "will never fail"
  - "zero downtime"
paths:
  - match: "Runbooks / Restart the worker"
    state: model_ok
  - match: "Runbooks / Restart the worker / Blast radius"
    state: human_only
  - match: "Runbooks / Disk full"
    state: hybrid
    require_rationale_marker: "<!-- ownership: human-rationale -->"
  - match: "Concepts / Why this topology"
    state: human_only
  - match: "Reference / Environment variables"
    state: model_ok
Enter fullscreen mode Exit fullscreen mode

Numbered workflow

1. Inventory heading paths from the live tree

Do not invent paths from memory, because stale outlines will classify the wrong sections. Walk the Markdown that reviewers actually read, then emit one heading chain per file. Keep the inventory command in the repository so the contract and the tree cannot drift in silence.

# Proposed inventory. Run from the repository root.
python3 scripts/inventory_headings.py docs > /tmp/heading-paths.txt
wc -l /tmp/heading-paths.txt
head -n 20 /tmp/heading-paths.txt
Enter fullscreen mode Exit fullscreen mode
# scripts/inventory_headings.py
# Proposed helper. Unexecuted in this article; treat output as a draft map.
from __future__ import annotations

import pathlib
import re
import sys

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


def chains_for(path: pathlib.Path) -> list[str]:
    stack: list[str] = []
    found: list[str] = []
    for raw in path.read_text(encoding="utf-8").splitlines():
        m = HEADING.match(raw.strip())
        if not m:
            continue
        level = len(m.group(1))
        title = re.sub(r"\s+", " ", m.group(2).strip())
        stack = stack[: level - 1] + [title]
        found.append(" / ".join(stack))
    return found


def main(root: str) -> None:
    base = pathlib.Path(root)
    for md in sorted(base.rglob("*.md")):
        rel = md.relative_to(base).as_posix()
        for chain in chains_for(md):
            print(f"{rel}\t{chain}")


if __name__ == "__main__":
    main(sys.argv[1] if len(sys.argv) > 1 else "docs")
Enter fullscreen mode Exit fullscreen mode

2. Label each path before any model call

Classify from the inventory, not from a prompt, because the model should never choose its own authority. Mark procedures and reference tables model_ok only when a later check can confirm commands, flags, or schema rows against the repository. Mark conceptual pages and every quotable promise human_only, including security posture and support scope. Mark incident runbooks hybrid when steps are mechanical but blast radius still needs a human sentence.

3. Draft only the allowed bodies in an isolated job

Keep generation off the documentation deploy path so a failed draft cannot publish itself. Teams without a paid inference budget can still run the isolated drafting job on MonkeyCode using free model access and a free server option. Disclosure: This article was prepared as part of MonkeyCode's product outreach. The contract and the classifier do not depend on that host, and any batch job that emits a labeled patch can be checked. Keep the product mention limited to the drafting environment so the ownership rules remain useful if that environment is replaced.

Pass the contract into the job as read-only input, and never paste human-only sections into the prompt. Ask the model for fenced commands, tables, and numbered steps under model_ok and hybrid paths only. Require the job to write a sidecar label such as X-Draft-Source: model so later classification does not guess authorship from style. If the job cannot label its own patch, treat the entire diff as model output and apply the strictest matching state.

# Proposed isolated draft. The host may vary; the label must not.
python3 scripts/draft_allowed_sections.py \
  --contract docs/draftability.yml \
  --tree docs \
  --out /tmp/model.patch \
  --source-label model
Enter fullscreen mode Exit fullscreen mode

4. Require a human rationale marker on every hybrid heading

Hybrid is not a loophole for rewriting the why in the same commit as the steps. After the model patch is applied in a working tree, each hybrid section must still contain the marker recorded in the contract. Humans add or refresh that marker in a separate hunk that the classifier treats as human-owned. If the marker disappears during regeneration, the job fails even when the surrounding steps look correct.

## Disk full

<!-- ownership: human-rationale -->
Page the on-call owner before deleting files, because this volume holds customer exports.

1. `df -h /var/lib/app`
2. `journalctl -u app -n 200`
3. Rotate logs only after the impact note above stays accurate.
Enter fullscreen mode Exit fullscreen mode

5. Classify the patch before merge

Do not review authorship by reading prose tone. Extract a unified diff against the protected branch, then run the classifier with the same contract the drafting job received. Fail closed on unknown files, unknown heading paths, missing hybrid markers, and any model hunk that matches a speech-act phrase. Keep the command in CI so local exceptions cannot become the merge path.

git fetch origin
git diff --unified=3 origin/main...HEAD -- docs > /tmp/docs.patch
python3 scripts/check_draft_patch.py \
  --contract docs/draftability.yml \
  --tree docs \
  --patch /tmp/docs.patch \
  --source-label model
Enter fullscreen mode Exit fullscreen mode
# scripts/check_draft_patch.py
# Proposed classifier. This article does not report production metrics.
from __future__ import annotations

import argparse
import pathlib
import re
import sys
from dataclasses import dataclass

import yaml  # PyYAML

HEADING = re.compile(r"^(#{1,6})\s+(.*)$")
HUNK = re.compile(r"^@@ -(\d+)(?:,\d+)? \+(\d+)(?:,\d+)? @@")


@dataclass(frozen=True)
class Rule:
    match: str
    state: str
    marker: str | None = None


def load_contract(path: pathlib.Path) -> tuple[str, list[str], list[Rule]]:
    data = yaml.safe_load(path.read_text(encoding="utf-8"))
    rules = [
        Rule(
            match=item["match"],
            state=item["state"],
            marker=item.get("require_rationale_marker"),
        )
        for item in data.get("paths", [])
    ]
    acts = [a.lower() for a in data.get("speech_acts", [])]
    return data.get("default_state", "human_only"), acts, rules


def state_for(chain: str, default: str, rules: list[Rule]) -> Rule:
    hits = [rule for rule in rules if chain == rule.match or chain.startswith(rule.match + " / ")]
    if not hits:
        return Rule(match=chain, state=default)
    hits.sort(key=lambda r: len(r.match), reverse=True)
    return hits[0]


def heading_map(text: str) -> list[tuple[int, str]]:
    stack: list[str] = []
    mapping: list[tuple[int, str]] = []
    for i, raw in enumerate(text.splitlines(), start=1):
        m = HEADING.match(raw.strip())
        if m:
            level = len(m.group(1))
            title = re.sub(r"\s+", " ", m.group(2).strip())
            stack = stack[: level - 1] + [title]
        mapping.append((i, " / ".join(stack) if stack else "(document root)"))
    return mapping


def changed_new_lines(patch: str) -> dict[str, set[int]]:
    current = None
    new_line = 0
    found: dict[str, set[int]] = {}
    for raw in patch.splitlines():
        if raw.startswith("+++") and not raw.startswith("+++ /dev/null"):
            current = raw[4:].strip()
            if current.startswith("b/"):
                current = current[2:]
            found.setdefault(current, set())
            continue
        hm = HUNK.match(raw)
        if hm and current:
            new_line = int(hm.group(2))
            continue
        if current is None or raw.startswith("---") or raw.startswith("diff ") or raw.startswith("index "):
            continue
        if raw.startswith("+"):
            found[current].add(new_line)
            new_line += 1
        elif raw.startswith("-"):
            continue
        else:
            new_line += 1
    return found


def main() -> int:
    p = argparse.ArgumentParser()
    p.add_argument("--contract", required=True)
    p.add_argument("--tree", required=True)
    p.add_argument("--patch", required=True)
    p.add_argument("--source-label", required=True)
    args = p.parse_args()
    default, acts, rules = load_contract(pathlib.Path(args.contract))
    failures: list[str] = []
    tree = pathlib.Path(args.tree)
    patch_text = pathlib.Path(args.patch).read_text(encoding="utf-8")
    model = args.source_label == "model"

    for rel, lines in changed_new_lines(patch_text).items():
        if not rel.endswith(".md"):
            continue
        path = pathlib.Path(rel)
        if not path.exists():
            # Fall back to the docs tree when the diff path is repo-relative.
            candidate = tree / path.name if not rel.startswith(str(tree)) else path
            if candidate.exists():
                path = candidate
            else:
                nested = tree / pathlib.Path(*path.parts[1:]) if path.parts else path
                path = nested if nested.exists() else path
        if not path.exists():
            failures.append(f"unknown file after patch: {rel}")
            continue
        text = path.read_text(encoding="utf-8")
        mapping = dict(heading_map(text))
        for lineno in sorted(lines):
            chain = mapping.get(lineno, "(document root)")
            rule = state_for(chain, default, rules)
            if not model:
                continue
            if rule.state == "human_only":
                failures.append(f"{rel}:{lineno} model hunk under human_only path: {chain}")
            if rule.state == "hybrid" and rule.marker and rule.marker not in text:
                failures.append(f"{rel} missing hybrid marker for {chain}")
            line = text.splitlines()[lineno - 1] if 0 < lineno <= len(text.splitlines()) else ""
            lowered = line.lower()
            for act in acts:
                if act in lowered:
                    failures.append(f"{rel}:{lineno} speech-act '{act}' in model hunk under {chain}")

    if failures:
        print("draftability check failed:")
        for item in failures:
            print(f"  - {item}")
        return 1
    print("draftability check passed")
    return 0


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

6. Merge only when the classifier is quiet and a human still owns the why

A green classifier is necessary and not sufficient, because hybrid markers can be stale even when they are present. Require a named reviewer on every path whose state is human_only or hybrid, recorded in CODEOWNERS or an equivalent review rule. Keep model-ok pages in the same pull request only when the diff is still small enough to skim heading by heading. If the patch rewrites an entire file as one blob, reject it and ask for a heading-scoped diff, because the classifier cannot defend a document that no longer has stable paths.

# .github/CODEOWNERS (proposed pairing with the contract)
/docs/concepts/ @docs-owners
/docs/runbooks/ @sre-oncall @docs-owners
/docs/draftability.yml @docs-owners
Enter fullscreen mode Exit fullscreen mode

Unexecuted test plan for the classifier

The cases below are a proposed harness, not results from a measured run. Add them as fixture patches before you trust the gate on a default branch. Each case should name the heading path, the source label, and the expected exit code so failures stay diagnosable.

  1. Model addition under Reference / Environment variables with no speech-act phrase; expect exit 0.
  2. Model addition under Concepts / Why this topology; expect exit 1 and a human_only failure line.
  3. Model rewrite of Runbooks / Disk full steps while the rationale marker remains; expect exit 0.
  4. Model rewrite of the same runbook after deleting the rationale marker; expect exit 1.
  5. Model line containing zero downtime on a model_ok how-to; expect exit 1 for the speech-act rule.
  6. Human-labeled patch on a human_only path; expect exit 0, because the denylist applies to model hunks only.
  7. New Markdown file with no contract entry; expect exit 1 from the human_only default.
  8. Whole-file replacement that drops heading structure; expect exit 1 for unknown or root paths.
# Proposed fixture loop. Do not treat a local pass as a published benchmark.
for case in tests/draftability/*.patch; do
  echo "CASE $case"
  python3 scripts/check_draft_patch.py \
    --contract docs/draftability.yml \
    --tree testdata/docs \
    --patch "$case" \
    --source-label model
done
Enter fullscreen mode Exit fullscreen mode

Limitations

Heading parsers fail when authors mix underline headings, skipped levels, or duplicate titles in one file. The classifier described here reads ATX headings only, so Setext pages and generated HTML dumps will map changes to the document root and then fail closed. Teams that let a model rewrite an entire guide as one paragraph also remove the path keys the contract needs, which means this workflow cannot repair blob-shaped diffs. Speech-act matching is literal and case-insensitive, so it will miss paraphrases and will false-positive on quoted incident language if those quotes appear inside a model hunk.

The contract also cannot see claims that live outside Markdown, including screenshots, diagrams, and video voiceover. It does not verify that a model_ok command still runs, and it does not prove that a human rationale is true. Free model access and a free server option change only where the isolated draft can run; they do not change review duty, and this article does not claim quotas, hardware, model names, duration, or permanence for that environment.

Who should not use this approach

Skip heading-path contracts when the documentation is fully generated from source and no human sentence is supposed to remain. API reference that is emitted from OpenAPI or rustdoc should stay on the code-generation path, because a draftability YAML file would only duplicate the schema. Skip it when no reviewer will own human_only paths, because a failing classifier without a human queue becomes a noisy gate that people will disable. Skip it for marketing pages, sales one-pagers, and legal terms, which need counsel rather than a speech-act regular expression.

Small libraries with three Markdown files may not need this machinery either. A labeled pull request and a single human editor already preserve authorship at that scale. Adopt the contract when a docs tree has stable heading paths, mixed runbooks, and a recurring model refresh that otherwise collapses why and how into one unreviewed hunk.

If a docs tree already has stable heading paths, apply this contract to one runbook and read the classifier output first.

Top comments (0)