DEV Community

Avery Lin
Avery Lin

Posted on

A Liability Map That Blocks Model Writes on Human-Owned Docs

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/**
Enter fullscreen mode Exit fullscreen mode

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.

  1. Classify every glob in docs/ownership.yaml and protect that file with CODEOWNERS.
  2. Build a worktree that contains Class D files plus read-only canonical inputs for Class C.
  3. Run the generator against that worktree, never against a full repository checkout.
  4. Copy candidates back only onto paths whose class allows draft or reformat_only.
  5. Run the checker on git diff --name-only --diff-filter=ACMRT and fail if a denied path appears.
  6. 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
Enter fullscreen mode Exit fullscreen mode

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.
Enter fullscreen mode Exit fullscreen mode

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())
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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)