Who Owns This Paragraph? An Ownership Manifest for AI-Drafted Docs
Here is a scenario that repeats in most repositories that let a model write documentation. A pull request adds a runbook section: rotate the API credential, then restart the worker. The diff is clean, the headings match the style guide, and the reviewer approves it in ninety seconds. Six weeks late

Here is a scenario that repeats in most repositories that let a model write documentation. A pull request adds a runbook section: rotate the API credential, then restart the worker. The diff is clean, the headings match the style guide, and the reviewer approves it in ninety seconds.
Six weeks later the steps are wrong. Rotation moved behind a queue, and the text now describes a procedure that no longer exists. Nobody can name the person who was supposed to catch that. The paragraph has an author. It has no owner.
That gap is the interesting part. Style is cheap to automate; accountability is not. What follows is a workflow that keeps generated prose draftable while keeping ownership human, plus the check that enforces the boundary in CI.
Better prompts do not fix any of these. A contract next to the document does.
Treat a document as a set of addressable blocks. Each block gets one of three classes:
A three-state enum is boring on purpose. It is easy to review and easy to test, and both of those matter more than expressiveness.
Blocks are delimited with HTML comments so they stay invisible when rendered:
<!-- block:rotation-steps --> 1. Drain the worker pool. 2. Rotate the credential in the secret store. 3. Restart the worker and confirm the health endpoint. <!-- /block --> Enter fullscreen mode Exit fullscreen mode The manifest then lists every block, its class, its owner, and its evidence:
# docs/ownership.yaml version: 1 documents: - path: docs/ops/rotate-api-credentials.md blocks: - id: overview class: draftable owner: ' @ops-docs' evidence: source_ref ref: ' services/auth/src/rotate.ts#L40-L96' - id: rotation-steps class: attestable owner: ' @ops-oncall' evidence: human_attestation ref: ' ops-review-log#R-2214' - id: customer-impact class: prohibited owner: ' @support-lead' evidence: human_attestation ref: ' reachability-review#R-88' Enter fullscreen mode Exit fullscreen mode Paths and review-log identifiers above are illustrative placeholders; substitute your own.
One script is enough to turn the manifest into a failing check:
#!/usr/bin/env python3 ''' check_ownership.py — fail when a docs block has no owner or no evidence. ''' from __future__ import annotations import json import sys from pathlib import Path import yaml # pip install pyyaml REQUIRED = { ' id ', ' class ', ' owner ', ' evidence ' } CLASSES = { ' draftable ', ' attestable ', ' prohibited ' } EVIDENCE = { ' source_ref ', ' command_output ', ' human_attestation ' } NEEDS_REF = { ' draftable ', ' attestable ' } def block_ids ( markdown: str ) -> set [ str ]: ''' Collect block markers: <!-- block:id -->... <!-- /block --> ''' found = set () for raw in markdown. splitlines (): line = raw. strip () if line. startswith ( ' <!-- block: ' ) and line. endswith ( ' --> ' ): found. add ( line [ len ( ' <!-- block: ' ): - 3 ]. strip ()) return found def check ( manifest_path: Path ) -> list [ str ]: errors: list [ str ] = [] data = yaml. safe_load ( manifest_path. read_text ( encoding = ' utf-8 ' )) if data. get ( ' version ' )!= 1: errors. append ( ' manifest: unsupported version ' ) for doc in data. get ( ' documents ', []): path = Path ( doc [ ' path ' ]) if not path. exists (): errors. append ( f ' { path }: declared but missing ' ) continue declared = { b [ ' id ' ]: b for b in doc. get ( ' blocks ', [])} present = block_ids ( path. read_text ( encoding = ' utf-8 ' )) for orphan in sorted ( present - declared. keys ()): errors. append ( f ' { path }: block { orphan!r} missing from manifest ' ) for ghost in sorted ( declared. keys () - present ): errors. append ( f ' { path }: manifest declares { ghost!r}, no such block ' ) for bid, block in declared. items (): missing = REQUIRED - block. keys () if missing: errors. append ( f ' { path } # { bid }: missing { sorted ( missing ) } ' ) continue cls = block [ ' class ' ] ev = block [ ' evidence ' ] if cls not in CLASSES: errors. append ( f ' { path } # { bid }: unknown class { cls!r} ' ) if ev not in EVIDENCE: errors. append ( f ' { path } # { bid }: unknown evidence { ev!r} ' ) if cls in NEEDS_REF and not block. get ( ' ref ' ): errors. append ( f ' { path } # { bid }: class { cls } needs a ref ' ) return errors if __name__ == ' __main__ ': target = Path ( sys. argv [ 1 ] if len ( sys. argv ) > 1 else ' docs/ownership.yaml ' ) failures = check ( target ) print ( json. dumps ({ ' failures ': failures }, indent = 2 )) sys. exit ( 1 if failures else 0 ) Enter fullscreen mode Exit fullscreen mode A pytest wrapper makes it part of the normal test run:
# tests/test_docs_ownership.py import subprocess import sys def test_ownership_manifest_is_satisfied (): result = subprocess. run ( [ sys. executable, ' tools/check_ownership.py ', ' docs/ownership.yaml ' ], capture_output = True, text = True, ) assert result. returncode == 0, result. stdout + result. stderr Enter fullscreen mode Exit fullscreen mode And the CI job is small enough to read in one screen:
name: docs-ownership on: pull_request: paths: - ' docs/**' - ' tools/check_ownership.py' - ' tests/test_docs_ownership.py' jobs: check: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - uses: actions/setup-python@v5 with: python-version: ' 3.12' - run: pip install pyyaml pytest - run: python tools/check_ownership.py docs/ownership.yaml - run: pytest -q tests/test_docs_ownership.py Enter fullscreen mode Exit fullscreen mode The decision table Block class Model may Human must Evidence key CI behaviour draftable write the first pass verify every factual claim against ref source_ref fails without ref attestable draft the skeleton only run each step and record the attestation human_attestation fails without ref prohibited nothing write it or approve it explicitly human_attestation review only One honest limit: detecting that a model touched a prohibited block requires your generator to record provenance. If it cannot, drop that claim and let review carry it rather than pretending the check proves it.
The manifest makes the draft step cheap and the gate non-negotiable. That split is where free infrastructure helps instead of getting in the way.
Disclosure: This article was prepared as part of MonkeyCode's product outreach.
Two caveats that matter more than the convenience. Quotas, limits, and availability on any free tier change, so check the current plan before routing a pipeline through it, and never treat a free tier as a production SLA. A cheap draft does not reduce what the human still owns.
You do not need to retrofit an entire docs tree. Pick one runbook, mark three blocks, write three manifest entries, and run the script once. If it passes on the first attempt, the manifest is usually too vague to be useful.
The rule worth keeping is short: the model may draft anything a human will read, and it may never own the sentence someone will act on. Keep the gate yours, and a free first pass is a reasonable place to begin.