DEV Community

Avery Lin
Avery Lin

Posted on

Split Drafting Rights by Section, Not by File: A Permit Manifest for Model-Written Docs

The useful question is not whether a model may write documentation, but which sections it may touch. File-level ownership is too coarse for that question, because a single API page mixes mechanical reference tables with compatibility promises a human has to defend. This article builds a small gate that answers the question mechanically: a permit manifest keyed by heading path, a differ that attributes changed lines to sections, and a CI check that fails when a model reaches into a human-owned block. The gate is roughly 90 lines of Python with no third-party dependencies, and you can run it against any repository that keeps docs in Markdown.

Why file-level ownership breaks down

Most repositories express doc ownership as CODEOWNERS on directories, which routes review but says nothing about drafting rights. A model that rewrites an install command and a model that quietly changes a deprecation date produce the same review signal: one modified file. Reviewers then spend attention uniformly across a diff whose sections carry wildly different risk. The gate below makes that asymmetry explicit and machine-readable, so review attention follows consequence instead of line count.

The distinction matters more as drafting volume grows. A model can propose five hundred words of setup prose per hour without fatigue, but it does not know which sentences your support team has promised to customers. Encoding that boundary once, in a manifest, is cheaper than re-litigating it in every pull request.

The division of labor

Write the split down before you write any automation. The table below is the one I use as a starting point; adjust the right column to your own risk tolerance.

Section type Who may draft Why
Install and quickstart commands Model Verifiable by running the commands
Field tables derived from source Model Diffable against code or schema
Changelog entries from commit messages Model Traceable to a commit hash
Error message index Model Enumerable from the codebase
Security posture and threat model Human Requires sign-off and liability
Pricing, quotas, and plan limits Human Commercial commitment
Compatibility and deprecation dates Human Time-bound promise to users
Incident timelines and postmortems Human Facts under dispute
Benchmarks and performance claims Human Needs a reproducible method

Two rules keep the table honest. First, a model-draftable section must be checkable by something other than the model, such as an executed command or a schema diff. Second, anything a customer could quote back to you in a support ticket belongs to a human.

Step 1: Describe permits in a manifest

The manifest stores drafting rights per heading path, with wildcards for whole files and subtrees. TOML keeps it stdlib-readable via tomllib on Python 3.11 and later.

# docperm.toml
version = 1
default_draft = "model"

[permits]
"docs/security.md#*" = "human"
"docs/pricing.md#*" = "human"
"docs/api.md#breaking-changes" = "human"
"docs/api.md#reference/*" = "model"
"docs/changelog.md#*" = "model"
Enter fullscreen mode Exit fullscreen mode

Lookup order is exact key, then progressively shorter prefix wildcards, then the file wildcard, then the default. That order lets you flip one subsection to human without restating the whole file. Keep the manifest next to the docs it governs so a change to drafting rights appears in the same diff as the pages it affects.

Step 2: Turn headings into addressable keys

A heading path is the stack of ancestor headings, slugged and joined with slashes. Hashing to a stable identifier would be sturdier against renames, but the readable path keeps violations legible in CI output.

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

def slug(text):
    s = re.sub(r"[^a-z0-9]+", "-", text.lower()).strip("-")
    return s or "section"

def sections_at(rev, path):
    """Return [(start_line, key)] for every heading in path at rev."""
    lines = git("show", f"{rev}:{path}").splitlines()
    stack, out = [], []
    for i, line in enumerate(lines, start=1):
        m = HEADING.match(line)
        if not m:
            continue
        level, title = len(m.group(1)), slug(m.group(2))
        while stack and stack[-1][0] >= level:
            stack.pop()
        stack.append((level, title))
        out.append((i, f"{path}#" + "/".join(t for _, t in stack)))
    return out
Enter fullscreen mode Exit fullscreen mode

Because the keys come from the post-change revision, a renamed heading produces a new key and therefore a fresh permit lookup. That is deliberate: renaming a human-owned section should require re-approval rather than inherit permission silently.

Step 3: Attribute diff lines to sections

git diff --unified=0 gives hunk headers precise enough to map every added line to the nearest preceding heading. Deleted lines carry only the old position, so this pass treats them as out of scope and handles whole-file deletions separately.

HUNK = re.compile(r"^@@ -\d+(?:,\d+)? \+(\d+)(?:,(\d+))? @@")

def changed_lines(base, head, path):
    diff = git("diff", "--unified=0", base, head, "--", path)
    out = set()
    for line in diff.splitlines():
        m = HUNK.match(line)
        if m:
            start, count = int(m.group(1)), int(m.group(2) or 1)
            out.update(range(start, start + count))
    return out

def attribute(sections, lines):
    keys = set()
    for ln in lines:
        best = None
        for start, key in sections:
            if start <= ln:
                best = key
            else:
                break
        keys.add(best or "?")
    return keys
Enter fullscreen mode Exit fullscreen mode

A changed line above the first heading maps to ?, which the checker always reports as a violation. Preamble text is where badges, banners, and version notices live, and those are exactly the lines people forget to govern.

Step 4: Fail the build, with an escape hatch

The check exits nonzero when a model-touched section is marked human. An owner trailer in the commit message downgrades the failure to a logged waiver, so a human editing their own section is not blocked.

def permit(key, permits, default):
    if key in permits:
        return permits[key]
    path, _, frag = key.partition("#")
    parts = frag.split("/")
    for n in range(len(parts) - 1, -1, -1):
        cand = f"{path}#" + "/".join(parts[:n] + ["*"])
        if cand in permits:
            return permits[cand]
    return permits.get(f"{path}#*", default)
Enter fullscreen mode Exit fullscreen mode

Run it locally before pushing, so the failure arrives while the author still has context:

python3 docperm.py --base origin/main --head HEAD --manifest docperm.toml
# exit 1 plus a report such as:
# VIOLATION docs/pricing.md#enterprise-tier (human-owned section changed)
Enter fullscreen mode Exit fullscreen mode

Wire the same command into CI on pull requests, and require the job in branch protection. Keep the report format stable if you parse it downstream for dashboards.

A test plan you can reproduce

Build a fixture repository with docs/api.md, docs/pricing.md, and an empty docperm.toml, then assert the exit code for each case. Five cases cover the behaviour that matters.

  1. Model edits docs/api.md#reference/fields — expect exit 0.
  2. Model edits docs/pricing.md#enterprise-tier — expect exit 1 with that key named.
  3. Same edit as case 2, with Doc-Approved-By: avery in the commit body — expect exit 0 plus a waiver line.
  4. Model adds text above the first heading of docs/api.md — expect exit 1 with the ? key.
  5. Model edits a heading title that a permit targets — expect exit 1, because the slug no longer matches the exact key.

Case 5 is the one people argue about, and the argument is the point: renaming a governed section should be a visible decision, not a silent permission inheritance.

Where assisted drafting fits

Nothing above depends on a vendor, which is why the drafting lane can stay interchangeable. I use MonkeyCode's free model access to produce candidate sections for the model-draftable rows, then run this checker before anything reaches a reviewer, so the gate — not the reviewer — catches boundary crossings. Disclosure: This article was prepared as part of MonkeyCode's product outreach. The free server option is a reasonable place to run this short-lived checker job if you keep it stateless and clone-based; I have not measured it against large repositories, so time your own clone step first. If you want to try the drafting lane before wiring the gate, run a handful of reference sections through the manifest above and see which keys fire.

Limitations and who should skip this

Line attribution by nearest preceding heading misattributes code blocks that contain # comments at column zero, so strip fenced blocks before parsing in repositories where that occurs. Whole-file deletions need a separate git diff --diff-filter=D check wired to the same trailer rule. The manifest does not prove a section is correct; it only proves who was allowed to touch it. Teams without branch protection, or with a single reviewer and no docs review step, will get more value from a short owner list in the contributing guide than from a CI job nobody reads. Finally, if your documentation is generated entirely from code annotations, the heading tree is derivable and this manifest should be generated too, not hand-maintained.

The boundary between drafted and owned text is a policy decision that no tool can make for you. Writing it into a permit manifest makes the decision testable, reviewable, and cheap to revisit as your documentation risk profile changes.

Top comments (0)