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
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)
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
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
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
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}"
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
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
---
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))
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)