DEV Community

Avery Lin
Avery Lin

Posted on

Block Model Drafts Until Each Docs Path Declares an Owner Class

Generated documentation pipelines fail most often when they assume every markdown path is eligible for a model draft. Cheaper drafts raise the cost of missing ownership, so encode who may write each file before assembling a prompt. This article specifies an ownership manifest, a small checker, and a four-class decision table that keep human-owned narrative out of generation. The method remains useful even if operators later replace the drafting service or the surrounding CI job.

Ownership is not a claim type

Heading taxonomies and claim-type contracts answer what a section is allowed to say after drafting starts. Ownership answers a prior question: whether a model is allowed to produce that section at all. Mixing those questions produces a familiar failure, in which a model writes a fluent page that no named human will defend. Reviewers then spend time editing tone instead of rejecting the wrong author for the wrong documentation path.

Four owner classes cover most documentation repositories without collapsing into a binary allow-or-deny switch. The classes are recorded in a committed manifest, not inferred from file mtime or from a model's confidence score. Inference from confidence is unreliable because fluency and authority are not the same operational signal. A path that looks complete can still be a policy document that must stay human-authored.

Owner-class decision table

Use this table as the contract that later scripts enforce. Without an explicit class, prompt templates tend to treat every docs/**/*.md glob as draftable, which is the default that produces unowned pages.

Class Model may draft? Human must own? Typical paths Merge rule
DRAFTABLE Full prose from listed artifacts Review only Generated API tables, changelog fragments from git CI lint plus one reviewer
STUB_ONLY Outline and required headings Body, examples, and decisions Onboarding guides, architecture decision records Human fills every body slot
QUOTE_BOUND No paraphrase; copy listed quotes Surrounding narrative Legal notices, SLA excerpts Quote checker on merge
HUMAN_OWNED No generation Headings and body Incidents, security policy, pricing Block the generation job

The important column is not “model quality.” It is whether a mistaken draft on that path would create a compliance, safety, or trust problem. If the answer is yes, the class is HUMAN_OWNED even when a model could write a smoother paragraph.

1. Commit an ownership manifest

Place docs/ownership.yaml at the documentation root so review of ownership is a normal pull request. Each rule is a glob, an owner class, and a named human or team that remains accountable after merge. Globs are evaluated in order, and the first match wins, which keeps the file readable during review.

version: 1
defaults:
  class: HUMAN_OWNED
  accountable: docs-oncall
rules:
  - glob: "docs/reference/api/**/*.md"
    class: DRAFTABLE
    accountable: api-platform
  - glob: "docs/changelog/*.md"
    class: DRAFTABLE
    accountable: release-eng
  - glob: "docs/adr/**/*.md"
    class: STUB_ONLY
    accountable: architecture
  - glob: "docs/legal/**/*.md"
    class: QUOTE_BOUND
    accountable: legal
  - glob: "docs/incidents/**/*.md"
    class: HUMAN_OWNED
    accountable: sre
  - glob: "docs/security/**/*.md"
    class: HUMAN_OWNED
    accountable: security
Enter fullscreen mode Exit fullscreen mode

Defaulting to HUMAN_OWNED is the conservative choice for a docs tree that mixes reference and policy. New paths then fail closed until someone writes an explicit rule, which is cheaper than discovering an unowned security page after publication. Treat an unmatched path as a manifest bug, not as an invitation to generate.

2. Resolve class before prompt assembly

The checker must run before any prompt is built, not after the model returns prose. Post-hoc deletion still leaks human-owned context into logs and vendor history. Resolution is a pure function of the target path plus the manifest, which makes it testable without network access.

# ownership_check.py
from __future__ import annotations

import sys
from pathlib import Path
import fnmatch
import yaml

ALLOWED_DRAFT = {"DRAFTABLE", "STUB_ONLY", "QUOTE_BOUND"}


def load_manifest(path: Path) -> dict:
    data = yaml.safe_load(path.read_text())
    if not data or "rules" not in data:
        raise ValueError("ownership.yaml must contain a rules list")
    return data


def resolve(path: str, manifest: dict) -> dict:
    for rule in manifest["rules"]:
        if fnmatch.fnmatch(path, rule["glob"]):
            return rule
    defaults = manifest.get("defaults", {})
    return {
        "glob": "*",
        "class": defaults.get("class", "HUMAN_OWNED"),
        "accountable": defaults.get("accountable", "unassigned"),
    }


def assert_draft_allowed(target: str, manifest: dict) -> None:
    rule = resolve(target, manifest)
    if rule["class"] == "HUMAN_OWNED":
        raise SystemExit(
            f"blocked: {target} is HUMAN_OWNED "
            f"(accountable={rule['accountable']})"
        )
    if rule["class"] not in ALLOWED_DRAFT:
        raise SystemExit(
            f"blocked: unknown class {rule['class']} for {target}"
        )
    print(
        f"ok: {target} class={rule['class']} "
        f"accountable={rule['accountable']}"
    )


if __name__ == "__main__":
    target = sys.argv[1]
    manifest = load_manifest(Path("docs/ownership.yaml"))
    assert_draft_allowed(target, manifest)
Enter fullscreen mode Exit fullscreen mode

A one-line preflight then becomes part of the generation job. The second command must fail the job, not continue with a warning that humans later ignore.

python ownership_check.py docs/reference/api/users.md
python ownership_check.py docs/security/threat-model.md
# expected: second command exits non-zero and stops the job
Enter fullscreen mode Exit fullscreen mode

Wire the same command into the docs workflow so a glob typo is caught on the pull request that introduced it.

# .github/workflows/docs-ownership.yml
name: docs-ownership
on:
  pull_request:
    paths:
      - "docs/**"
      - "ownership_check.py"
      - "test_ownership_check.py"
jobs:
  check:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-python@v5
        with:
          python-version: "3.12"
      - run: pip install pyyaml pytest
      - run: pytest test_ownership_check.py -q
Enter fullscreen mode Exit fullscreen mode

3. Constrain the prompt by class, not by enthusiasm

Once the class is known, the prompt template should change with the class. DRAFTABLE paths may receive source artifacts such as OpenAPI fragments and git logs. STUB_ONLY paths may receive a heading skeleton and a list of required empty sections, but not a request for finished prose. QUOTE_BOUND paths may receive an allowlisted quotation file and a hard instruction to copy those strings verbatim.

HUMAN_OWNED paths never reach this stage. If a developer needs a private sketch, they write it locally without sending the path through the shared pipeline, which keeps vendor logs clean. Permission lives in the manifest, not in a reviewer's later discomfort with the generated tone.

When a path is DRAFTABLE, a free drafting environment is usually enough for a first pass from listed artifacts. Disclosure: This article was prepared as part of MonkeyCode's product outreach. MonkeyCode's free model access and free server option can run that constrained drafting step after the ownership checker has already excluded human-owned paths. The checker, not the model, remains the source of permission, and the server does not need extra claims about hardware or quotas to be useful here.

A minimal class-aware prompt builder can stay boring on purpose. Boring is easier to audit than a single mega-prompt that tries to cover incidents and API tables together.

# prompt_for_class.py
TEMPLATES = {
    "DRAFTABLE": (
        "Draft only from the listed artifacts. "
        "Do not add policy, incidents, or pricing."
    ),
    "STUB_ONLY": (
        "Emit headings and empty placeholders only. "
        "Do not write body paragraphs or examples."
    ),
    "QUOTE_BOUND": (
        "Copy allowlisted quotations verbatim. "
        "Do not paraphrase. Leave narrative slots empty."
    ),
}


def build_prompt(owner_class: str, artifacts: str) -> str:
    if owner_class not in TEMPLATES:
        raise ValueError(f"no prompt for class {owner_class}")
    return TEMPLATES[owner_class] + "\n\n" + artifacts
Enter fullscreen mode Exit fullscreen mode

4. Verify the gate with fixture paths

Do not trust a single happy-path run against one API file. Keep a fixture list that encodes expected classes and assert both allow and deny outcomes in CI. The unmatched-path case is the one that usually ships by accident, so include a file that no glob covers.

# test_ownership_check.py
from pathlib import Path
from ownership_check import load_manifest, resolve

MANIFEST = load_manifest(Path("docs/ownership.yaml"))

CASES = [
    ("docs/reference/api/users.md", "DRAFTABLE"),
    ("docs/changelog/2026-09.md", "DRAFTABLE"),
    ("docs/adr/0007-event-bus.md", "STUB_ONLY"),
    ("docs/legal/privacy.md", "QUOTE_BOUND"),
    ("docs/incidents/2026-09-01.md", "HUMAN_OWNED"),
    ("docs/security/threat-model.md", "HUMAN_OWNED"),
    ("docs/unlisted/new-guide.md", "HUMAN_OWNED"),
]


def test_resolve_classes():
    for path, expected in CASES:
        got = resolve(path, MANIFEST)["class"]
        assert got == expected, f"{path}: {got} != {expected}"
Enter fullscreen mode Exit fullscreen mode

Run the tests on every change to the manifest, because a typo in a glob silently reclassifies a security page as draftable. A green generation job that never executed these cases is not evidence that ownership works.

pytest test_ownership_check.py -q
Enter fullscreen mode Exit fullscreen mode

5. Record the class in the generated file header

Draftable output should declare its class so later readers do not mistake generated tables for human policy. A short front matter block is enough and stays machine-readable for later linters.

---
owner_class: DRAFTABLE
accountable: api-platform
generated: true
source_artifacts:
  - openapi/users.yaml
---
Enter fullscreen mode Exit fullscreen mode

Human-owned files should omit generated: true and should fail CI if that key appears. The inversion is intentional: generation is the marked state, and human narrative is the default unmarked state. A linter that only looks for missing front matter on generated files will miss the more serious case, which is a generated flag on an incident report.

# fail if HUMAN_OWNED files claim to be generated
import sys
from pathlib import Path
import yaml
from ownership_check import load_manifest, resolve

manifest = load_manifest(Path("docs/ownership.yaml"))
errors = []
for path in Path("docs").rglob("*.md"):
    rel = path.as_posix()
    rule = resolve(rel, manifest)
    text = path.read_text()
    if not text.startswith("---"):
        continue
    parts = text.split("---", 2)
    if len(parts) < 3:
        continue
    meta = yaml.safe_load(parts[1]) or {}
    if rule["class"] == "HUMAN_OWNED" and meta.get("generated") is True:
        errors.append(rel)

if errors:
    sys.exit("generated flag on human-owned paths: " + ", ".join(errors))
Enter fullscreen mode Exit fullscreen mode

Limitations

The manifest does not grade factual accuracy, tone, or completeness. A DRAFTABLE page can still be wrong, and the accountable team still reviews it. The checker also cannot see prose that a model writes into a draftable file while discussing a human-owned incident, so prompt content still needs a separate redaction step. Multi-file pages that transclude human-owned fragments need a slot-and-link pattern rather than this path-level gate alone.

Operators who publish a single README with mixed policy and generated tables will fight the glob model. Split those files first, or the first-match rule will either over-block or over-permit. Teams without a named on-call for documentation should not adopt this workflow, because HUMAN_OWNED with accountable: unassigned is only a comment in YAML.

Who should skip this approach

Skip the manifest if the repository contains only generated API reference and no policy, incidents, or pricing. Skip it if legal review already wraps every docs pull request in a slower process that makes a pre-generation gate redundant. Skip it if the team cannot run a local YAML checker in CI, because an unenforced manifest becomes decorative configuration and trains people to ignore it.

The practical test is simple and does not require a quality benchmark. If a mistaken model draft on one path would create a compliance or trust problem, that path needs an owner class before the prompt exists. Commit the manifest, run the fixture tests, and keep generation behind the first failing glob rather than behind a more persuasive template.

Top comments (0)