DEV Community

Avery Lin
Avery Lin

Posted on

Assign Draft and Sign Roles to Docs Paths Before Generation

Documentation generation fails when a model is allowed to write every path in the tree. Regenerable examples and command listings can be drafted from the current repository state without extra claims. Policy text, deprecation copy, security notes, and license summaries cannot be inferred from that same state. Bind each documentation path to a draft role or a sign role before any generation run starts.

Path roles replace prompt-only discipline

Prompt instructions do not survive the next editor, the next model, or the next intern. A path-level ownership manifest lives in git and fails continuous integration when a role is violated. That difference is mechanical rather than rhetorical, which is the only control that holds under regeneration. The sections below specify the manifest, a write fence, a checker, and a generation sequence.

This workflow is for repositories that already keep documentation beside code and regenerate some of it. It is not a style guide and it is not a claim lexicon. The unit of control is the path, because reviewers merge files, not paragraphs hiding inside a prompt transcript.

Draft-role paths and their allowed contents

Draft-role files are those a later commit can rebuild from tree state without changing a promise. Install command blocks that are sourced from a Makefile belong here because that file remains authoritative. Request fixtures, response fixtures, and endpoint indexes derived from a committed OpenAPI document also qualify. Generated flag tables that mirror a CLI parser belong here as well, provided no extra guarantee is added.

Draft-role files must carry a machine header that names the generator input and the generation timestamp. They must not contain SLA figures, CVE identifiers, calendar dates for retirement, or contractual verbs. If a draft file needs one of those tokens, the path should be reclassified rather than patched in review. Reclassification is a human change to the manifest and should appear in the same pull request.

Sign-role paths and their required headers

Sign-role files hold statements whose truth sits outside the snapshot a generator can read. Security reporting contacts, deprecation calendars, data-retention rules, and license interpretations sit in this class. Trademark lines, export-control notes, and status-page URLs also sit here because they are operational facts. A model may emit a skeleton comment, but continuous integration must reject an unsigned replacement of the prior blob.

Every sign-role file starts with a SIGNED-BY header that names a reviewer and a reviewed commit. The checker compares the current blob against the last signed digest when the header is missing or stale. Stale means the path changed while the signed commit no longer matches HEAD for that file. Reviewers refresh the header only after reading the full file, not after skimming a generated diff.

Artifact: ownership manifest, write fence, and checker

The artifact is a committed YAML manifest plus a pre-generation write fence and a post-generation checker. The manifest maps glob patterns to roles, required headers, and a short list of banned token classes. The write fence is a small wrapper that denies create and update operations on sign-role paths. The checker runs in continuous integration and also locally before a pull request is opened.

# docs/ownership.yaml
version: 1
default_role: human_sign
roles:
  model_draft:
    header: GENERATED-FROM
    write: generator
  human_sign:
    header: SIGNED-BY
    write: human
paths:
  - glob: docs/examples/**/*.md
    role: model_draft
    inputs: ["Makefile", "openapi.yaml", "fixtures/**"]
  - glob: docs/generated/cli-flags.md
    role: model_draft
    inputs: ["cmd/**/*.go"]
  - glob: docs/generated/endpoint-index.md
    role: model_draft
    inputs: ["openapi.yaml"]
  - glob: SECURITY.md
    role: human_sign
  - glob: docs/policy/**/*.md
    role: human_sign
  - glob: docs/legal/**/*.md
    role: human_sign
banned_in_draft:
  - "\\bSLA\\b"
  - "\\bCVE-\\d{4}-\\d+\\b"
  - "\\b(deprecat|sunsets?|end-of-life)\\b"
  - "\\b(warrant|indemnif|guarantee)\\b"
  - "\\b99\\.\\d+%\\b"
  - "\\b(forever|always available)\\b"
Enter fullscreen mode Exit fullscreen mode

Store the last signed digest beside the manifest so the checker can detect silent overwrites.

# docs/ownership-signlog.tsv
# path<TAB>sha256<TAB>signed_by<TAB>commit
SECURITY.md 4b1c... alice   abc1234
docs/policy/deprecation.md  91aa... bob def5678
Enter fullscreen mode Exit fullscreen mode

The following checker is a labeled, unexecuted example that you should adapt to the repository layout. It reads the manifest, classifies staged documentation paths, and fails the run when a role rule is broken.

#!/usr/bin/env python3
"""docs_ownership_check.py — fail CI when draft/sign roles are violated."""
from __future__ import annotations

import hashlib, os, re, subprocess, sys
from fnmatch import fnmatch
from pathlib import Path

try:
    import yaml
except ImportError:
    sys.exit("PyYAML is required for docs/ownership.yaml")

ROOT = Path(__file__).resolve().parents[1]
MANIFEST = ROOT / "docs" / "ownership.yaml"
SIGNLOG = ROOT / "docs" / "ownership-signlog.tsv"


def git_staged_docs() -> list[str]:
    out = subprocess.check_output(
        ["git", "diff", "--cached", "--name-only", "--", "docs", "SECURITY.md"],
        text=True,
    )
    return [line.strip() for line in out.splitlines() if line.strip()]


def load_manifest() -> dict:
    data = yaml.safe_load(MANIFEST.read_text())
    if not data or "paths" not in data:
        raise SystemExit("ownership manifest missing paths")
    return data


def role_for(path: str, manifest: dict) -> dict:
    matched = None
    for rule in manifest["paths"]:
        if fnmatch(path, rule["glob"]):
            matched = rule
    if matched is None:
        return {"glob": "(default)", "role": manifest.get("default_role", "human_sign")}
    return matched


def header_present(text: str, name: str) -> bool:
    return any(line.startswith(f"<!-- {name}:") or line.startswith(f"{name}:")
               for line in text.splitlines()[:12])


def sha256(path: Path) -> str:
    return hashlib.sha256(path.read_bytes()).hexdigest()


def load_signlog() -> dict[str, str]:
    if not SIGNLOG.exists():
        return {}
    rows = {}
    for line in SIGNLOG.read_text().splitlines():
        if not line or line.startswith("#"):
            continue
        path, digest, *_ = line.split("\t")
        rows[path] = digest
    return rows


def main() -> int:
    manifest = load_manifest()
    banned = [re.compile(p, re.I) for p in manifest.get("banned_in_draft", [])]
    signlog = load_signlog()
    failures: list[str] = []
    fence = os.environ.get("DOCS_GENERATOR", "") == "1"

    for path in git_staged_docs():
        rule = role_for(path, manifest)
        role = rule["role"]
        full = ROOT / path
        if not full.exists():
            continue
        text = full.read_text(errors="replace")
        if fence and role == "human_sign":
            failures.append(f"write-fence: generator touched sign-role path {path}")
            continue
        if role == "model_draft":
            if not header_present(text, "GENERATED-FROM"):
                failures.append(f"{path}: missing GENERATED-FROM header")
            for cre in banned:
                if cre.search(text):
                    failures.append(f"{path}: banned draft token /{cre.pattern}/")
        elif role == "human_sign":
            if not header_present(text, "SIGNED-BY"):
                failures.append(f"{path}: missing SIGNED-BY header")
            previous = signlog.get(path)
            current = sha256(full)
            if previous and previous != current and "SIGNED-BY" not in text[:400]:
                failures.append(f"{path}: blob changed without a refreshed signature")
        else:
            failures.append(f"{path}: unknown role {role}")

    if failures:
        print("docs ownership check failed:")
        for item in failures:
            print(f"  - {item}")
        return 1
    print(f"docs ownership check passed for {len(git_staged_docs())} path(s)")
    return 0


if __name__ == "__main__":
    raise SystemExit(main())
Enter fullscreen mode Exit fullscreen mode

Wrap generation behind a fence so the model process cannot open sign-role files for write. The wrapper below is deliberately small and should be the only entry point used by automation.

#!/usr/bin/env bash
# tools/generate-draft-docs.sh
set -euo pipefail
export DOCS_GENERATOR=1
python3 tools/docs_ownership_check.py || true

# Refuse to start if sign-role paths are already staged.
staged="$(git diff --cached --name-only -- docs SECURITY.md)"
while IFS= read -r path; do
  [ -z "$path" ] && continue
  role="$(python3 - <<'PY' "$path"
import sys, yaml
from fnmatch import fnmatch
path = sys.argv[1]
data = yaml.safe_load(open("docs/ownership.yaml"))
role = data.get("default_role", "human_sign")
for rule in data["paths"]:
    if fnmatch(path, rule["glob"]):
        role = rule["role"]
print(role)
PY
)"
  if [ "$role" = "human_sign" ]; then
    echo "write-fence: refusing to generate into $path" >&2
    exit 2
  fi
done <<< "$staged"

# Replace this with the repository's real generator command.
python3 tools/render_draft_docs.py --only-role model_draft
python3 tools/docs_ownership_check.py
Enter fullscreen mode Exit fullscreen mode

Numbered workflow

1. Inventory every documentation path that merge review actually sees

List files under docs/, plus root files such as SECURITY.md and CODE_OF_CONDUCT.md. Ignore editor scratch files and generated folders that are not committed. Capture the inventory in the pull request that introduces the manifest, so later role changes remain auditable. A short command is enough for the first pass.

git ls-files 'docs/**' SECURITY.md CODE_OF_CONDUCT.md LICENSE
Enter fullscreen mode Exit fullscreen mode

2. Assign each glob a draft role or a sign role, with no leftover default surprises

Put regenerable material under explicit model_draft globs and keep the default role as human_sign. That default is conservative: an unclassified path cannot be overwritten by a generator. Record the generator inputs beside each draft glob so reviewers can see the rebuild recipe. Do not encode reviewer names in the glob list; names belong in the signlog after a human read.

3. Install the write fence as the only generator entry point

Point Make, Task, or CI at tools/generate-draft-docs.sh and delete ad-hoc prompt scripts from contributor docs. Set DOCS_GENERATOR=1 only inside that wrapper so local editing by humans is not blocked. Confirm the fence with a negative test that tries to write SECURITY.md and expects exit status 2. Keep the wrapper in the same commit as the manifest so the control lands atomically.

.PHONY: docs-draft docs-check
docs-draft:
    bash tools/generate-draft-docs.sh
docs-check:
    python3 tools/docs_ownership_check.py
Enter fullscreen mode Exit fullscreen mode

4. Generate only draft-role paths, then run the checker on the staged tree

Disclosure: This article was prepared as part of MonkeyCode's product outreach. A generation step can use free model access and a free server when only draft-role files are required. The ownership checker does not depend on that workspace and should run in ordinary continuous integration after drafting. Do not treat the draft output as signed copy, even when the generation environment is convenient and inexpensive.

Stage only the files the generator is allowed to touch, then run docs-check before any human review. If banned tokens appear, reclassify the path or strip the sentence; do not weaken the regex to keep a green build. Timestamp the GENERATED-FROM header with the input commit, not with a marketing phrase about freshness.

<!-- GENERATED-FROM: openapi.yaml@7f3c1aa CLI parser@cmd/root.go -->
<!-- GENERATED-AT: 2026-09-14T00:00:00Z -->
Enter fullscreen mode Exit fullscreen mode

5. Sign or reject every staged change on a sign-role path

Humans edit sign-role files in a separate commit from generator output whenever possible. After the edit, refresh SIGNED-BY and append a row to docs/ownership-signlog.tsv. The signlog digest must match the file bytes that will land on the default branch. A reviewer who only comments in the hosting UI has not signed the path.

<!-- SIGNED-BY: Avery Lin reviewed-commit=9ae21c0 date=2026-09-14 -->
Enter fullscreen mode Exit fullscreen mode

6. Gate the pull request with the same checker the laptop ran

Run python3 tools/docs_ownership_check.py on staged files in CI, not on an arbitrary working tree. Fail the job on any write-fence violation, missing header, banned draft token, or stale signlog digest. Keep the job required so a green generator cannot merge through an optional check. When the job fails, the fix is a role change or a human signature, not a prompt tweak.

# .github/workflows/docs-ownership.yml
name: docs-ownership
on: [pull_request]
jobs:
  check:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - run: pip install pyyaml
      - run: python3 tools/docs_ownership_check.py
Enter fullscreen mode Exit fullscreen mode

Reproducible test plan

Label the cases below as a plan, not as production metrics. Each case should be committed as a fixture repository or as unit tests around role_for and header_present.

  1. Staging docs/examples/create-widget.md with a GENERATED-FROM header and no banned tokens must pass. Staging the same file with the sentence "99.9% uptime guarantee" must fail on the banned-token rule. Staging SECURITY.md while DOCS_GENERATOR=1 must fail on the write fence. Staging docs/policy/deprecation.md without SIGNED-BY must fail even when the model output looks fluent.
  2. Changing a sign-role blob while leaving the signlog digest untouched must fail. Refreshing both the header and the digest after a human edit must pass. Adding a new path that matches no glob must inherit human_sign and therefore fail generation. Reclassifying that path to model_draft in ownership.yaml in the same commit must pass if headers are valid.
# tests/test_docs_ownership.py — labeled example, extend before relying on it
from fnmatch import fnmatch

def role_for(path, rules, default="human_sign"):
    role = default
    for glob, assigned in rules:
        if fnmatch(path, glob):
            role = assigned
    return role

def test_unclassified_path_is_signed():
    rules = [("docs/examples/**/*.md", "model_draft")]
    assert role_for("docs/policy/retention.md", rules) == "human_sign"

def test_last_matching_glob_wins():
    rules = [
        ("docs/**/*.md", "human_sign"),
        ("docs/examples/**/*.md", "model_draft"),
    ]
    assert role_for("docs/examples/list.md", rules) == "model_draft"
Enter fullscreen mode Exit fullscreen mode

Limitations

Path roles do not read the meaning of a sentence that avoids banned tokens through paraphrase. A draft file can still imply a support promise with softer wording that no regex will catch. The signlog proves that a human touched a digest, not that the statements inside remain true against production. Header timestamps can be forged if the checker only inspects text and never compares generator inputs.

Globs can overlap, and the sample checker uses last-match-wins rather than a scored specificity function. Teams with nested packages may need longer rule lists and a test that every committed docs path matches exactly one intended role. Binary diagrams and generated images are out of scope unless you extend the digest logic beyond UTF-8 markdown. This article does not claim uptime, model quality, or review-time reductions, because those numbers were not measured here.

Who should not use this approach

Do not use path roles as a substitute for legal review on license, privacy, or export text. Do not point a generator at customer-facing contracts, status-page copy, or incident reports and then rely on the fence alone. Single-file README repositories gain little, because almost every sentence is a sign-role statement. Highly regulated releases that already require dual control should keep their existing approval tools and treat this checker as an extra gate at most.

Skip the workflow if nobody will maintain the signlog when files move. A stale manifest that still lists deleted globs creates false confidence and trains reviewers to ignore CI. If the documentation set is handwritten and rarely regenerated, a generator write fence is ceremony without a failure mode. In that case, keep review checklists and leave the model out of the docs tree.

Closing

Models may draft files whose bytes are a pure function of committed inputs; humans must own every path that states a duty. Put that split in docs/ownership.yaml, deny generator writes to sign-role globs, and fail the merge when headers or digests disagree. If a shared free server is already part of the drafting loop, run the ownership checker on the same tree before opening the pull request.

Top comments (0)