DEV Community

Avery Lin
Avery Lin

Posted on

Mark Doc Sections as Draftable, Review-Gated, or Human-Only

Generated documentation fails less from weak prose than from the wrong author writing the wrong section. A short ownership ledger can freeze human-only policy, allow model drafts for recoverable facts, and require named reviewers before publish. The rest of this article specifies that ledger, a validator, and a five-step pipeline you can run locally. None of the figures below are traffic or quality benchmarks; they are structural checks you can reproduce from files.

Why empty headings are not a drafting invitation

Language models will complete any heading you leave empty, including headings that should never leave a human keyboard. Security exceptions, incident blame, pricing promises, and support SLAs look like ordinary prose to a draft model. Teams then spend review cycles arguing about tone while the real defect is still missing ownership. An explicit ledger turns that social argument into a merge gate that a script can test.

Three rights per heading path

This workflow defines exactly three machine-checked rights for every heading path in the documentation tree. draftable means a model may emit full prose if evidence files exist beside the heading. review_gated means the model may emit an outline, questions, and cited facts, never a closing recommendation. human_only means the heading body is stripped before any prompt is built, and a signature must already exist.

These classes describe permission rather than literary voice, so shared tone never implies shared rights. A reference table can list HTTP status codes as draftable while the adjacent support SLA stays human_only. Mixing those two on one model call is the failure mode this ledger exists to prevent. Reviewers then spend time on judgment, not on hunting which paragraph should not have been generated.

1. Encode the ledger beside the docs tree

Keep the ledger in docs/ownership.yaml so path rights travel with the same pull request as the prose. Each record names a heading path, a right, a human role, and a reviewer identity that CI can resolve. Paths are slash-joined heading titles rather than file offsets, which keeps the ledger stable when paragraphs move. Unknown paths are not silently drafted: default_right is human_only, and the checker still fails the build until a human lists the path.

version: 1
default_right: human_only
paths:
  - path: "API / Error catalog"
    right: draftable
    owner_role: docs-eng
    reviewer: docs-oncall
    evidence_glob: "evidence/errors/*.json"
  - path: "API / Rate limits"
    right: review_gated
    owner_role: platform
    reviewer: api-leads
    evidence_glob: "evidence/limits/*.md"
  - path: "Support / Response commitments"
    right: human_only
    owner_role: support-lead
    reviewer: vp-support
    evidence_glob: ""
  - path: "Security / Exception log"
    right: human_only
    owner_role: security
    reviewer: security-chair
    evidence_glob: ""
Enter fullscreen mode Exit fullscreen mode

The four records above are a fixture for tests, not a survey of any particular company. You should replace the role strings with reviewer identities that your existing review tool already understands. Leave evidence_glob empty on human_only paths so a generator cannot pretend it found a source. Commit the file before the first model run so rights are not negotiated inside a chat transcript.

2. Validate the ledger before any prompt is built

Run a small checker that loads the YAML, walks Markdown heading paths, and exits non-zero on contradictions. The script below is labeled as a local utility; it does not call a network model. Place the checker at tools/check_ownership.py and keep the Markdown fixture in tests/fixtures/docs.

#!/usr/bin/env python3
"""Validate docs/ownership.yaml against Markdown heading paths."""
from __future__ import annotations

import pathlib
import re
import sys
from typing import Any

import yaml

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


def heading_paths(md: str) -> list[str]:
    stack: list[str] = []
    paths: list[str] = []
    for line in md.splitlines():
        match = HEADING.match(line)
        if not match:
            continue
        level = len(match.group(1))
        title = match.group(2).strip()
        stack = stack[: level - 1]
        stack.append(title)
        paths.append(" / ".join(stack))
    return paths


def section_body(md: str, path: str) -> str:
    lines = md.splitlines()
    collecting = False
    body: list[str] = []
    current: list[str] = []
    for line in lines:
        match = HEADING.match(line)
        if match:
            level = len(match.group(1))
            title = match.group(2).strip()
            current = current[: level - 1] + [title]
            if collecting:
                return "\n".join(body)
            collecting = " / ".join(current) == path
            continue
        if collecting:
            body.append(line)
    return "\n".join(body)


def load_ledger(path: pathlib.Path) -> dict[str, Any]:
    data = yaml.safe_load(path.read_text())
    if data.get("version") != 1:
        raise SystemExit("ownership.yaml version must be 1")
    if data.get("default_right") != "human_only":
        raise SystemExit("default_right must be human_only")
    seen: set[str] = set()
    for row in data.get("paths", []):
        if row["path"] in seen:
            raise SystemExit(f"duplicate path: {row['path']}")
        if row["right"] not in RIGHTS:
            raise SystemExit(f"unknown right on {row['path']}")
        if row["right"] != "human_only" and not row.get("evidence_glob"):
            raise SystemExit(f"missing evidence_glob on {row['path']}")
        if row["right"] == "human_only" and row.get("evidence_glob"):
            raise SystemExit(f"human_only path must not list evidence: {row['path']}")
        seen.add(row["path"])
    return data


def main() -> int:
    root = pathlib.Path("docs")
    ledger = load_ledger(root / "ownership.yaml")
    allowed = {row["path"]: row for row in ledger["paths"]}
    failures: list[str] = []
    for md_file in sorted(root.rglob("*.md")):
        text = md_file.read_text()
        for path in heading_paths(text):
            row = allowed.get(path)
            if row is None:
                failures.append(f"{md_file}: unlisted path must be added as human_only: {path}")
                continue
            if row["right"] == "human_only":
                if "SIGNED-BY:" not in section_body(text, path):
                    failures.append(f"{md_file}: human_only path missing SIGNED-BY: {path}")
    for line in failures:
        print(line, file=sys.stderr)
    return 1 if failures else 0


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

That checker encodes three hard rules that you can explain in a single review comment. Unlisted heading paths must fail closed rather than inheriting draftable rights from a sibling heading. Draftable and review_gated rows must point at evidence globs, while human_only rows must not. Human-only bodies must contain a SIGNED-BY: token before merge, which is a process check rather than a cryptographic proof.

3. Build prompts from rights, never from empty space

After the checker passes, a generator may read only the paths whose right is draftable or review_gated. Human-only bodies are replaced with a placeholder token so the model never sees the current signature or the prohibited narrative. Review-gated prompts ask for bullets and open questions, and they forbid modal verbs that close a policy. Draftable prompts may request complete paragraphs, still constrained by the evidence glob listed on that row.

# Proposed prompt fragments; do not send human_only bodies.
PROMPT_RULES = {
    "draftable": (
        "Write complete paragraphs for this heading only. "
        "Use the attached evidence files. Do not mention other headings."
    ),
    "review_gated": (
        "Return an outline, open questions, and quoted evidence only. "
        "Do not recommend a policy, SLA, price, or exception."
    ),
}
STRIP_TOKEN = "[[HUMAN_ONLY_SECTION_OMITTED]]"
Enter fullscreen mode Exit fullscreen mode

A second command then writes those drafts into docs/_generated/ rather than into the human tree. That split keeps git blame useful on owned sections and makes generated output easy to delete. If you already have a docs CI job, add the checker as a required status and keep generation optional. Optional generation is important: a red ownership check should block merge even when nobody asked a model to write.

When a laptop queue is inconvenient, MonkeyCode's free model access and free server option can run the same draft job against docs/_generated/. Disclosure: This article was prepared as part of MonkeyCode's product outreach. The ownership checker still runs first on your tree, so a hosted runner cannot widen rights that the ledger did not grant. Treat the hosted path as a convenience for the draftable class, not as a replacement for the reviewer named on each row.

4. Apply a publish gate that differs by right

Use one decision table at publish time so reviewers do not invent ad hoc exceptions in chat. Reviewers should quote the matching row in the pull request instead of restating a taste argument. The generator must refuse to write into any cell that the table marks as human supply. Publish tooling then checks the last column and fails the job when a required human artifact is missing.

Right Model may emit Human must supply Publish if
draftable Full prose drawn from the listed evidence glob Reviewer acknowledgement on the pull request Checker is green and the glob matched at least one file
review_gated Outline, questions, and quoted evidence A decision sentence plus SIGNED-BY: Human decision is present and model text has no closing policy verbs
human_only Nothing; body omitted from the prompt Full body plus SIGNED-BY: Generator path left the body unchanged

That table is the shared artifact that reviewers should quote when a generated draft overreaches. A review_gated section that contains "we will always" or "guaranteed" fails the publish gate even if the outline is otherwise tidy. A draftable section that cites a file outside its glob also fails, because extra files are a back door into human_only topics. Keep the verb list short and versioned next to the ledger so arguments stay about the list, not about taste.

FORBIDDEN_IN_REVIEW_GATED = re.compile(
    r"\b(guarantee|we will always|sla|penalty|exception granted)\b",
    re.I,
)
Enter fullscreen mode Exit fullscreen mode

Proposed publish check, still local and model-free: scan docs/_generated/ for review_gated paths, apply the regex, and fail if any match remains after a human edit pass. Store the verb list in docs/ownership.verbs.txt so a pull request can change policy language without rewriting the checker. Do not treat a clean regex as proof that the outline is safe to paste into a customer-facing SLA.

5. Record a dry-run fixture so the pipeline is testable without a model

Store a tiny documentation tree under tests/fixtures/ownership/ and then assert the checker exit codes. The fixture needs one draftable page, one review_gated page, and one human_only page with a missing signature. A fourth page that contains an unlisted heading should fail closed under the default right. None of these tests require a live model, which keeps the suite deterministic on every commit.

tests/fixtures/ownership/docs/api.md
tests/fixtures/ownership/docs/support.md
tests/fixtures/ownership/docs/orphan.md
tests/fixtures/ownership/docs/ownership.yaml
Enter fullscreen mode Exit fullscreen mode
cd tests/fixtures/ownership
python3 ../../../../tools/check_ownership.py; echo $?
# expected: non-zero
# reports missing SIGNED-BY on Support / Response commitments
# reports unlisted path on the orphan heading
Enter fullscreen mode Exit fullscreen mode

Add a second fixture where every human_only path is signed and every other path lists an evidence glob. That green run is the baseline you compare against whenever someone proposes a new heading. If the new heading is not in the ledger, the suite fails until a human chooses a right. That delay is the entire point of the workflow, not a defect you should automate away.

Limitations

This ledger does not measure factual accuracy, readability, or later customer comprehension of the page. A draftable section can still misread a JSON evidence file and pass every structural gate in this article. Review-gated outlines can smuggle a recommendation through synonyms that the small forbidden-verb list does not catch. Human signatures prove that a named role touched the file, not that the signer had sufficient context.

The simple heading-path scheme also breaks whenever two pages reuse the same title chain. You then need file-scoped paths such as api.md#API / Error catalog, which the sample script does not implement. Treat generated API reference pages as one draftable blob that the spec compiler already owns. Do not rewrite those compiler pages as a forest of model-written essays under this ledger.

Who should not use this approach

Skip the ledger if a single human already writes every page and never calls a model. Skip it if your docs are compiled entirely from OpenAPI or proto comments and no narrative layer exists. Skip it if ownership in your org is political theater: a YAML file cannot create a reviewer who will not actually read. Skip it if you need cryptographic attestation; SIGNED-BY: is a merge convention, not a signature scheme.

Do not point this workflow at legal contracts, medical guidance, or production incident reports that require counsel review. Those documents need a different chain of custody than a docs CI job can provide. The ownership ledger remains a merge convention for product documentation, not a substitute compliance program. Using that ledger as theater around high-risk text only creates a false sense of control.

What to keep when you delete the generator

The durable output of this workflow is the ledger plus the checker, not any particular draft. Keep defaulting unknown paths to human_only so new headings cannot silently become model territory. Keep generation in a side directory so the owned narrative remains obvious in review diffs. Reuse the same YAML and the same exit codes when you change generators, rather than widening rights for convenience.

Top comments (0)