DEV Community

Avery Lin
Avery Lin

Posted on

Draft by Model, Decide by Human: A Track Split for Generated Docs

A documentation pipeline should not treat every sentence as equally generated, because the cost of a wrong claim is not uniform across sections. Split the work into two tracks: the model drafts anything a script can verify, while a named human owns anything that requires judgment. This track split turns review from a full-file rewrite into a small decision surface, and it keeps bot-generated PRs away from sections they cannot reason about. The workflow below gives you a machine-readable boundary, a CI check that enforces it, and a clear list of the cases where the split fails.

The ownership problem is not the prose quality

Model-generated documentation usually reads well on merge day and means very little after the next release changes the system. The defects that survive review are rarely grammar mistakes; they are claims nobody verified, such as a parameter default from two releases ago or a quickstart that never ran. A top DEV discussion this week asked the same question in a wider form, noting that AI promoted every developer to reviewer while nobody tested the reviewer. Documentation has the identical gap, and the fix is to decide who may write each claim rather than who reviews it afterward.

The boundary is not code versus prose, and it is not reference versus tutorial, because either side can hide an unverified claim. The boundary is verifiability: can a script check the claim by execution, comparison, or schema validation? Parameter tables, error catalogs, CLI output samples, and migration snippets are draftable because a script can confirm them against the running system. Design rationale, tradeoffs, deprecation strategy, and security caveats are human-owned because no test suite can tell whether they are true.

Two tracks, defined by what a script can prove

The classification below is the core artifact of this workflow, and it should be reviewed as seriously as the checker itself. Keep it narrow at first, and move paths back to the human side as soon as a defect survives the automated checks.

Document part Track Verifiable by Who may commit
CLI reference, parameter table Draftable Run the CLI and diff the output Bot or human
Error code catalog Draftable Compare against the source registry Bot or human
Quickstart scaffold Draftable Execute the finished example Bot or human
Architecture rationale Human Code review judgment Named human
Deprecation notes Human Roadmap review Named human
Security caveats Human Threat review Named human

Write the boundary in a file so CI can enforce it. A docs-tracks.yaml manifest maps path patterns to tracks and lists the named owners for the human side.

tracks:
  draftable:
    patterns:
      - docs/reference/**
      - docs/cli/*.md
      - docs/catalog/errors/*.md
  human:
    patterns:
      - docs/decisions/**
      - docs/architecture/**
      - docs/security/**
    owners:
      - '@platform-lead'
      - '@security-lead'
Enter fullscreen mode Exit fullscreen mode

Route generation through a scripted server

The draftable track only becomes cheap if generation runs from a script instead of a chat pane. MonkeyCode's free server option lets you drive the drafting step from the command line, while its free model access removes token-cost pressure from a track that produces many small drafts. Disclosure: This article was prepared as part of MonkeyCode's product outreach. Free tiers typically carry rate and concurrency limits, so measure those constraints in your own CI before generation becomes a blocking dependency.

The prompt file itself is the interface; it declares the target path and the track, so the script refuses to generate for a human-owned target.

---
target: docs/cli/config-options.md
track: draftable
---

Generate a parameter table for the config command from the CLI help output.
Enter fullscreen mode Exit fullscreen mode
#!/usr/bin/env bash
# generate-draft.sh — turn a prompt file into a draftable-track commit
set -euo pipefail

PROMPT="$1"
DRAFT_DIR="${DRAFT_DIR:-build/drafts}"

TARGET="$(grep '^target:' "$PROMPT" | cut -d' ' -f2)"
TRACK="$(grep '^track:' "$PROMPT" | cut -d' ' -f2)"

if [[ "$TRACK" != "draftable" ]]; then
  echo "Refusing to generate for track '$TRACK'" >&2
  exit 1
fi

if [[ -z "${DOCS_GEN_CMD:-}" ]]; then
  echo "DOCS_GEN_CMD is not set" >&2
  exit 1
fi

mkdir -p "$DRAFT_DIR/$(dirname "$TARGET")"

# Replace the placeholder with the invocation your server mode documents.
"${DOCS_GEN_CMD}" "$PROMPT" > "$DRAFT_DIR/$TARGET"

git add "$DRAFT_DIR/$TARGET"
git commit -m "docs(draft): generate $TARGET [generated]"
Enter fullscreen mode Exit fullscreen mode

The commit trailer matters as much as the script, so mark every generated commit with [generated] and add the same marker to the PR body for label-based filtering.

Enforce the split with one CI check

CI needs to answer one question before review starts: did an automated author touch a path that is marked human-owned? The checker below reads the track file, loads the changed files, and fails the build when the answer is yes.

#!/usr/bin/env python3
'''Fail a generated PR when it touches a human-owned doc path.'''
import fnmatch
import json
import os
import pathlib
import sys

import yaml

CONFIG = pathlib.Path('docs-tracks.yaml')
BOT_AUTHORS = {'github-actions[bot]', 'dependabot[bot]', 'docs-bot'}


def track_for(path: str, config: dict) -> str:
    for track_name in ('draftable', 'human'):
        for pattern in config['tracks'][track_name]['patterns']:
            if fnmatch.fnmatch(path, pattern):
                return track_name
    return 'draftable'


def main() -> int:
    config = yaml.safe_load(CONFIG.read_text())
    changed = json.loads(os.environ['CHANGED_FILES'])
    author = os.environ['PR_AUTHOR']
    labels = json.loads(os.environ.get('PR_LABELS') or '[]')

    generated = author in BOT_AUTHORS or 'generated' in labels
    violations = [p for p in changed if generated and track_for(p, config) == 'human']

    if violations:
        print('Generated PR touched human-owned paths:')
        for path in violations:
            print(f'  - {path}')
        return 1

    print(f'OK: {len(changed)} doc path(s), {len(violations)} track violation(s)')
    return 0


if __name__ == '__main__':
    sys.exit(main())
Enter fullscreen mode Exit fullscreen mode

Wire the job into GitHub Actions ahead of your existing example runner, because the order matters: track first, then execution, then human review.

# .github/workflows/doc-ownership.yml
name: doc-ownership
on:
  pull_request:
    types: [opened, synchronize]
    paths: ['docs/**']

jobs:
  enforce-tracks:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-python@v5
        with:
          python-version: '3.12'
      - run: pip install pyyaml
      - name: Collect changed doc files
        id: changes
        uses: actions/github-script@v7
        with:
          script: |
            const { data } = await github.rest.pulls.listFiles({
              owner: context.repo.owner,
              repo: context.repo.repo,
              pull_number: context.issue.number,
            })
            const changed = data
              .map((f) => f.filename)
              .filter((f) => f.startsWith('docs/'))
            core.setOutput('changed_files', JSON.stringify(changed))
            core.setOutput('author', context.payload.pull_request.user.login)
            core.setOutput(
              'labels',
              JSON.stringify(context.payload.pull_request.labels.map((l) => l.name)),
            )
      - name: Enforce doc tracks
        env:
          CHANGED_FILES: ${{ steps.changes.outputs.changed_files }}
          PR_AUTHOR: ${{ steps.changes.outputs.author }}
          PR_LABELS: ${{ steps.changes.outputs.labels }}
        run: python3 scripts/check_ownership.py
Enter fullscreen mode Exit fullscreen mode

Pin every action to a release your organization trusts, and update the Python version when your CI policy moves ahead.

Putting the workflow together

  1. Inventory every documentation path and classify it with the verifiability test from the decision table above.
  2. Write docs-tracks.yaml, add check_ownership.py, and set DOCS_GEN_CMD in your CI environment.
  3. Generate each draftable page from a prompt file, keeping the [generated] marker on every bot commit.
  4. Run the ownership check before the example runner, so a blocked path never wastes execution time.
  5. Require a named owner review for human-owned files, while draftable files merge on automated checks and a quick glance.

As an illustration, a pipeline that releases twenty reference pages would send all twenty through the draftable track and only the changed decision pages to named owners. The human review queue shrinks from twenty files to two, and the two files are exactly the ones where judgment adds value.

Limitations and when to skip this workflow

The manifest is path-based, so a page that mixes rationale and reference in one file cannot be split cleanly; either move the rationale to its own page or accept a coarser rule for that path. Small repositories with one active maintainer will find the YAML and checker overhead larger than the review time it saves. Free server and free model options usually impose rate limits and queueing, so a blocking generation step will expose every constraint at the worst moment. The checker blocks bots, but it cannot create ownership; a human-owned file with no active owner stays as risky as a file with no owner at all. Teams with stable, slow-moving documentation gain little from a second track and should keep their existing review flow.

The split only works when both tracks are honest: draftable means a script can verify it, not low priority. Start with the narrowest set of draftable paths, observe the first few generated PRs, and move any path back to the human side when a defect survives. If you try this routing, I would like to hear which section your team moved back first.

Top comments (0)