A documentation generator should never write every Markdown file in a repository without a liability map. The useful split is not abstract taste about machine prose, but which pages create external obligations when wrong. Records, promises, and identity copy remain human-owned, while recoverable how-to sections can be drafted by a model. This article specifies four classes, a YAML ownership map, and a pre-commit checker that fails closed.
The failure this map is built to stop
Mixed documentation pipelines fail in a boring way that ordinary pull-request review often misses until a customer files a ticket. A model refreshes a tutorial and, in the same commit, rewrites a support sentence that a human had negotiated. Git blame then names a bot, while the published site still presents that sentence as a company promise. The damage is not the model sounding unlike the team; the damage is an unowned obligation reaching production.
Reviewers cannot hold the full documentation tree in working memory during every scheduled generation run. A class map reduces the decision to a path check that continuous integration can fail closed. The generator receives only the paths it is allowed to touch, and human-owned files never enter the draft workspace. This framing treats the generator like an untrusted writer that is given a deliberately narrow working tree.
Four liability classes
Assign every documentation glob to exactly one class before any model run starts. If a path matches two globs, the stricter class wins, and that tie-break lives in the same file. Reviewers should not invent local exceptions during a late-night merge. The table below is a proposed contract, not a survey of any particular company corpus.
| Class | Name | Model write | Human duty | Typical paths |
|---|---|---|---|---|
| A | Record | deny | author, date, and keep chronology |
CHANGELOG.md, release notes, incident timelines |
| B | Promise | deny | product or counsel sign-off |
SECURITY.md, SLA pages, license excerpts |
| C | Interface restatement | reformat only | own the canonical table in source | error catalogs, CLI flag lists copied from specs |
| D | Recoverable guide | draft allowed | review before publish | tutorials, how-to guides, internal runbooks |
Class A is a historical record, so omitted entries and invented dates are false records rather than style defects. Class B creates obligations to customers, regulators, or employees, and a generator must not author or paraphrase those sentences. Identity copy such as product names in legal position statements belongs here as well, because a fluent rewrite can still change who is promising what.
Class C looks draftable, and that is exactly why it needs its own rule. A model can add an error code the service does not emit, and support will treat the page as an interface. The human owns the table in OpenAPI, proto, or a checked-in catalog; a model may only reformat rows that already exist. Class D is the only class where a full prose draft is in scope, because a wrong step is recoverable by running the procedure against a real environment.
The ownership file is itself a Class B surface
Keep the map next to the docs root and mark that file as human-owned. If the generator can edit the map, the boundary is theater and the checker will bless its own expansion. A proposed layout follows; treat it as a starting contract rather than an observed production dump.
# docs/ownership.yaml — proposed contract
version: 1
strictest_match: true
human_owned_file: docs/ownership.yaml
rank:
A: 4
B: 3
C: 2
D: 1
classes:
A:
write: deny
globs:
- CHANGELOG.md
- docs/releases/**
- docs/incidents/**
B:
write: deny
globs:
- SECURITY.md
- docs/legal/**
- docs/support/sla.md
- docs/ownership.yaml
C:
write: reformat_only
canonical_from:
- openapi.yaml
globs:
- docs/api/errors.md
- docs/cli/flags.md
D:
write: draft
globs:
- docs/guides/**
- docs/tutorials/**
- docs/runbooks/**
The reformat_only value is a policy flag, not a model feature. Your checker must compare Class C output against the canonical table and reject added keys. Without that comparison, Class C collapses into unsupervised drafting with nicer headings.
Numbered workflow
Follow these steps in order. Skipping the export step is how human-owned files leak into the generation workspace and then into the prompt.
- Classify every glob in
docs/ownership.yamland protect that file withCODEOWNERS. - Build a worktree that contains Class D files plus read-only canonical inputs for Class C.
- Run the generator against that worktree, never against a full repository checkout.
- Copy candidates back only onto paths whose class allows
draftorreformat_only. - Run the checker on
git diff --name-only --diff-filter=ACMRTand fail if a denied path appears. - Require a named reviewer on Class C diffs even when the checker reports a clean path list.
A matching CODEOWNERS fragment keeps GitHub or GitLab from merging map edits without the docs owner. Proposed lines look like the block below and should list people or teams that actually hold the obligation, not a shared bot account.
# Proposed CODEOWNERS fragment
docs/ownership.yaml @docs-owners
SECURITY.md @security-owners
docs/legal/ @legal-owners
CHANGELOG.md @release-owners
Sparse checkout is a practical way to build the narrow worktree without copying secrets or Class A chronology into the job. The sequence below is a proposed operator script, not a timed benchmark.
# Proposed: export only draftable globs into an isolated worktree
git fetch origin main
git worktree add --detach /tmp/docs-gen origin/main
cd /tmp/docs-gen
git sparse-checkout init --cone
git sparse-checkout set docs/guides docs/tutorials docs/runbooks
cp /path/to/repo/openapi.yaml /tmp/docs-gen/openapi.yaml
chmod a-w /tmp/docs-gen/openapi.yaml
# Generator runs here. Copy Class D files back through a reviewed patch.
The chmod a-w line does not replace the checker. It only makes accidental overwrite of the canonical table harder during a local run. Class C restatement should still be a structured transform over parsed keys, not a free rewrite of the Markdown file.
A proposed CI checker
The Python below is a proposed gate you can run as a pre-commit hook or a required CI job. It reads the YAML map, classifies each changed path, and exits nonzero when a denied path is in the diff. It also extracts backtick tokens from Class C Markdown and rejects tokens that do not appear in the canonical file. It does not score prose quality and it does not replace human review on Class D.
#!/usr/bin/env python3
"""Proposed CI gate: block model writes on Class A/B documentation paths."""
from __future__ import annotations
import fnmatch
import re
import subprocess
import sys
from pathlib import Path
try:
import yaml
except ImportError:
sys.stderr.write("PyYAML is required to read docs/ownership.yaml\n")
raise SystemExit(2)
ROOT = Path(__file__).resolve().parents[1]
OWN = ROOT / "docs" / "ownership.yaml"
TOKEN = re.compile(r"`([A-Za-z][A-Za-z0-9_.:-]+)`")
def load_contract() -> dict:
data = yaml.safe_load(OWN.read_text(encoding="utf-8"))
if data.get("human_owned_file") != "docs/ownership.yaml":
raise SystemExit("ownership.yaml must declare itself human-owned")
return data
def changed_paths() -> list[str]:
out = subprocess.check_output(
["git", "diff", "--name-only", "--diff-filter=ACMRT", "HEAD"],
text=True,
)
return [line.strip() for line in out.splitlines() if line.strip()]
def class_for(path: str, contract: dict) -> str | None:
rank = contract["rank"]
hits: list[tuple[int, str]] = []
for name, spec in contract["classes"].items():
for glob in spec["globs"]:
if fnmatch.fnmatch(path, glob):
hits.append((rank[name], name))
if not hits:
return None
hits.sort(reverse=True)
return hits[0][1]
def class_c_added_tokens(path: str, canonical: Path) -> set[str]:
body = (ROOT / path).read_text(encoding="utf-8")
allowed = set(TOKEN.findall(canonical.read_text(encoding="utf-8")))
found = set(TOKEN.findall(body))
return found - allowed
def main() -> int:
contract = load_contract()
denied = []
class_c_errors = []
canonical = ROOT / contract["classes"]["C"]["canonical_from"][0]
for path in changed_paths():
klass = class_for(path, contract)
if klass is None:
denied.append(f"{path}: unclassified (deny by default)")
continue
write = contract["classes"][klass]["write"]
if write == "deny":
denied.append(f"{path}: class {klass} is human-owned")
elif write == "reformat_only":
extra = class_c_added_tokens(path, canonical)
if extra:
class_c_errors.append(f"{path}: extra tokens {sorted(extra)}")
for line in denied + class_c_errors:
sys.stderr.write(line + "\n")
if denied or class_c_errors:
sys.stderr.write("docs ownership gate failed\n")
return 1
return 0
if __name__ == "__main__":
raise SystemExit(main())
Wire it as a path-filtered job so the gate runs whenever documentation or the map changes. A proposed GitHub Actions step is shown next; adjust the runner and Python version to whatever your repository already pins.
# Proposed CI fragment
- name: Docs ownership gate
run: |
pip install pyyaml
python tools/check_docs_ownership.py
Unclassified paths fail closed on purpose. A new docs/marketing/ folder should not become Class D because nobody updated the map. Adding a glob is a human edit to a Class B file, which is the point of the CODEOWNERS rule.
Where an isolated generator belongs
Class D still needs a model and a machine that cannot see Class A chronology or Class B promises. After the sparse worktree exists, that job can run on a laptop, a throwaway container, or a separate server that receives only the exported tree. MonkeyCode's free model access and free server option can host that isolated job when you do not want the generator sharing a checkout with legal Markdown. Disclosure: This article was prepared as part of MonkeyCode's product outreach.
The remote job should consume the exported tree and return a patch that your checker still evaluates. Merge authority stays in the repository, including when the candidate bytes were produced elsewhere. Do not upload CHANGELOG.md, SECURITY.md, or docs/ownership.yaml to the generation workspace as "context," because context is how Class B sentences get paraphrased into a tutorial preface.
Limitations
Glob matching is not semantics. A human-owned promise pasted into docs/guides/intro.md will pass this gate, because the path is Class D even though the sentence is Class B. Teams that copy SLA language into tutorials need an additional phrase denylist or a review checklist for those files. The token check for Class C is similarly shallow: it catches new backtick identifiers, not a rewritten meaning around an existing code.
The checker looks at git diff against HEAD, so a dirty generation that never stages files will look clean. Run it after git add of the candidate patch, or point it at git diff --cached in the hook. Sparse checkout will not help if your generator tool walks ../ and reads the parent clone. Bind the process root and pass an explicit allowlist of input files rather than a repository path.
This workflow also does nothing for unpublished drafts stored outside git. A model that writes into a wiki, a CMS, or a vendor help center bypasses CODEOWNERS entirely. If publication does not go through the same repository, the liability map has to live in that publishing system or the split is incomplete.
Who should not use this split
Skip the map if the repository is a personal README with no external promises and no generated pages. The YAML, hook, and sparse worktree cost more than they save when one person owns every sentence. Skip it for labeling that is itself a regulated artifact, such as medical, aviation, or safety manuals, where counsel already requires a human author of record on every change and a generator should not touch the tree at all.
Skip it when the "generator" is only rewriting punctuation inside files a human already drafted in the same commit. Liability classes earn their keep when a scheduled job proposes many files at once. If your process never lets a model open a write handle, you already have a stricter control than this article describes.
The ownership file is the piece worth adding first if you already generate guides and you already review them in git. Keep that file in the same change as the generator, not in a later cleanup pull request that nobody wants to block.
Top comments (0)