Generated documentation stays safe only when draft authority follows the product lifecycle, not the writer's convenience. Models may draft experimental pages while a feature remains unreleased, because those pages can be deleted without customer impact. After general availability, humans must own support statements, migration notes, and breaking-change language, because a wrong sentence becomes a contract. The rest of this article specifies a lifecycle authority map, a repository file, and a checker that rejects unauthorized model edits.
Why a single generation policy fails
Teams often toggle generation on or off for an entire handbook, which treats a changelog bullet like a support promise. That binary policy either blocks useful drafts for unreleased flags or lets a model rewrite pages customers already quote. Cost of a wrong claim tracks release state more closely than it tracks heading depth or writing style. Unreleased text can be wrong in public without creating a support incident, while GA text is operationally binding.
Recent developer discussion around agent workflows keeps returning to one failure: the system assumes facts nobody verified. Documentation generation fails in the same way when the model invents timelines, supported versions, or rollback advice. A lifecycle map does not make the model wiser; it only removes the pages where an assumption would ship as policy. Treat this workflow as a control plane for writers, not as a quality model for generated prose.
The lifecycle × section decision table
Classify every documentation unit by two axes that a release engineer can verify without reading the prose. The first axis is feature lifecycle: experimental, preview, generally available, or deprecated, taken from the product catalog. The second axis is section class: derived reference, task procedure, migration narrative, or support commitment. The cell at their intersection is the only draft authority the generator is allowed to claim.
| Lifecycle | Derived reference (OpenAPI, flags, CLI help) | Task procedure | Migration narrative | Support commitment (versions, SLA, breaking changes) |
|---|---|---|---|---|
| experimental | model may draft | model may draft | human must own | human must own |
| preview | model may draft | model may propose; human merges | human must own | human must own |
| generally available | model may draft if rebuildable | human must own | human must own | human must own |
| deprecated | model may draft sunset tables from the catalog | human must own | human must own | human must own |
The label model may propose means the job writes a sidecar file and never touches the published path. The label model may draft means the job may overwrite the published path when machine sources actually changed. The label human must own means any generator-authored diff against that class fails the merge check. Those three labels are the entire vocabulary; do not add informal labels inside pull request descriptions.
1. Inventory features by release state
Start from the product catalog rather than from the docs tree, because filenames lag behind shipping reality. Export a list of feature identifiers with their current lifecycle, using the same names your feature-flag service already stores. Map each identifier to one or more documentation paths, including guides, API pages, and release-note fragments. Missing mappings are a freeze, and the generator must skip any path that has no catalog owner.
A minimal catalog extract can be a JSON or YAML file produced by the pipeline that publishes flag metadata. Keep lifecycle values in a closed enum so the checker can reject unknown states instead of guessing. Do not infer general availability from a marketing page, because marketing often leads the engineering tag. Prefer the git tag, the flag default, or the support matrix as the primary lifecycle source.
# docs/catalog.yml — example output from a flag pipeline, not live data
batch-export: experimental
audit-log: generally_available
2. Assign draft authority per section class
Apply the decision table without local exceptions, because hidden exceptions become a second and undocumented policy. Derived reference from OpenAPI or proto comments may be drafted in every lifecycle, provided a checker can rebuild the tables. Task procedures may be drafted in experimental and preview states, then frozen to human edits after the GA tag. Migration narratives and support commitments stay human-owned from the first public mention of a timeline or SLA.
Section class should be explicit in the heading or in an HTML comment, not inferred from verbs in the paragraph. A heading labeled Parameters can be derived reference, while Breaking changes remains a support commitment. If a heading cannot be classified, the default is human ownership, which fails closed under generation. That default is the entire point of the map, because silence from the catalog is not permission to draft.
3. Keep the map in the repository
Store the authority map beside the docs, not in a prompt, so review history and blame remain ordinary git objects. A YAML file is enough for a first implementation and stays readable in pull requests that change a lifecycle. Each record should name the feature, the lifecycle, the glob of paths, and the authority for every section class. When lifecycle changes, update the map in the same pull request that flips the flag or cuts the tag.
# docs/authority.yml — proposal: adapt identifiers to your catalog
version: 1
defaults:
unclassified_section: human_must_own
missing_feature: skip_generation
features:
- id: batch-export
lifecycle: experimental
paths:
- docs/guides/batch-export.md
- docs/api/batch-export.md
authority:
derived_reference: model_may_draft
task_procedure: model_may_draft
migration_narrative: human_must_own
support_commitment: human_must_own
- id: batch-export
lifecycle: generally_available
paths:
- docs/guides/batch-export.md
- docs/api/batch-export.md
- docs/support/batch-export.md
authority:
derived_reference: model_may_draft
task_procedure: human_must_own
migration_narrative: human_must_own
support_commitment: human_must_own
section_classes:
derived_reference:
headings: ["Parameters", "Flags", "Error codes", "CLI"]
task_procedure:
headings: ["Install", "Quickstart", "How to"]
migration_narrative:
headings: ["Migrate", "Upgrade", "From v1"]
support_commitment:
headings: ["Support", "SLA", "Breaking changes", "Supported versions"]
Only one lifecycle block per feature should be active; keep historical blocks for audit and gate them with the catalog. The generator reads the active block only, and the map constrains generator identity rather than ordinary reviewer edits. Reviewers should reject a lifecycle flip that does not update the matching documentation authority block in the same change. Stale maps are more dangerous than missing maps, because they grant draft rights the catalog has already withdrawn.
4. Generate only where the map allows
The generation job receives the map and a worklist of dirty paths, then drops any path whose authority is human. Remaining paths are assembled from machine sources such as OpenAPI, conventional commits, and failing test names. The model may rewrite only those remaining paths, and only the section classes marked draftable for that lifecycle. Everything else is copied forward unchanged, including headings the human already locked in a previous review.
A local generation job can call a model through MonkeyCode, which the operator describes as offering free model access and a free server option. Disclosure: This article was prepared as part of MonkeyCode's product outreach. The checker below does not depend on that vendor; it only gates which paths a draft job may write. If you run the job on a free server, keep the authority file and the source artifacts on the same checkout.
Label the generator commit with a stable trailer so later merge gates can see the author class. Use a dedicated trailer rather than the commit author email, because shared bots often reuse one identity. The checker treats any commit without that trailer as a human edit and will not block the paths.
git add docs/guides/batch-export.md
git commit -m "docs: refresh experimental batch-export draft
Generated-By: docs-generator
Authority-Map: docs/authority.yml"
Do not put customer-facing dates, pricing, or support windows into the generation prompt at any lifecycle. Those claims stay human-owned in every lifecycle of the decision table, including experimental public previews. If the source artifacts do not contain a value, the job must omit the sentence rather than guess.
5. Reject unauthorized diffs in CI
A checker compares the proposed diff against the map and fails when a human-owned path or section class changes. Model-authored commits should carry a trailer so the checker can distinguish generator edits from reviewer edits. Reviewer edits on GA support pages remain allowed; the control is on the generator identity, not on git itself. Run the checker locally before push, then again on the merge gate, so broken maps never reach the default branch.
The following script is a reference implementation rather than a measured production harness, and it remains unexecuted here. Adapt the heading parser to your Markdown dialect before you enforce it on a default branch. Wire the script as tools/check_doc_authority.py and fail the merge when the process returns a non-zero status. Pass the same catalog file the generator used, or the checker will evaluate a different product than the draft.
#!/usr/bin/env python3
"""Fail generator diffs that touch human-owned doc sections.
Proposal: run against a feature branch on the merge gate.
This example is unexecuted in this article; verify on a throwaway branch.
"""
from __future__ import annotations
import re
import subprocess
import sys
from pathlib import Path
import yaml
HEADING_RE = re.compile(r"^###?\s+(.*)\s*$")
TRAILER_RE = re.compile(r"^Generated-By:\s+\S+", re.M)
def load_map(path: Path) -> dict:
data = yaml.safe_load(path.read_text())
if data.get("version") != 1:
raise SystemExit("authority map version must be 1")
return data
def active_features(data: dict, catalog: dict) -> list:
active = []
for row in data["features"]:
current = catalog.get(row["id"])
if current and current == row["lifecycle"]:
active.append(row)
return active
def heading_class(heading: str, data: dict) -> str:
key = heading.strip().lower()
for cls, spec in data["section_classes"].items():
for item in spec["headings"]:
if key.startswith(item.lower()):
return cls
return "unclassified"
def generator_commit(message: str) -> bool:
return bool(TRAILER_RE.search(message))
def changed_files() -> list:
out = subprocess.check_output(
["git", "diff", "--name-only", "origin/main...HEAD"],
text=True,
)
return [line for line in out.splitlines() if line]
def section_hunks(path: str) -> list:
out = subprocess.check_output(
["git", "diff", "-U0", "origin/main...HEAD", "--", path],
text=True,
)
found = []
current = "unclassified"
for line in out.splitlines():
if line.startswith("+++") or line.startswith("---"):
continue
body = line[1:] if line[:1] in "+- " else line
match = HEADING_RE.match(body)
if match:
current = match.group(1)
if line.startswith("+") or line.startswith("-"):
found.append(current)
return found
def main() -> int:
authority = load_map(Path("docs/authority.yml"))
catalog = yaml.safe_load(Path("docs/catalog.yml").read_text())
message = subprocess.check_output(
["git", "log", "-1", "--format=%B"], text=True
)
if not generator_commit(message):
return 0
features = active_features(authority, catalog)
violations = []
for path in changed_files():
if not path.startswith("docs/"):
continue
owners = [
row for row in features
if any(Path(path).match(pattern) for pattern in row["paths"])
]
if not owners:
violations.append(f"{path}: no active feature mapping")
continue
auth = owners[0]["authority"]
for heading in section_hunks(path):
cls = heading_class(heading, authority)
rule = auth.get(
cls, authority["defaults"]["unclassified_section"]
)
if rule == "human_must_own":
violations.append(
f"{path}: generator touched {cls} ({heading})"
)
if rule == "model_may_propose":
violations.append(
f"{path}: proposal must use a sidecar, not {path}"
)
if violations:
print("authority check failed:")
print("\n".join(violations))
return 1
return 0
if __name__ == "__main__":
sys.exit(main())
python3 -m pip install pyyaml
chmod +x tools/check_doc_authority.py
git fetch origin main
python3 tools/check_doc_authority.py; echo $?
A non-zero status means the generator wrote outside its allowed lifecycle cell and must be rewritten. Fix the map, move the offending text to a sidecar file, or drop the hunk before merge. Do not add a skip comment that disables the checker for GA support pages under time pressure.
Limitations and who should skip this
This workflow assumes a catalog of features with trustworthy lifecycle labels, which many early products do not have. It also assumes section classes can be detected from headings or HTML ids, which poorly structured books will break. The checker cannot detect a human pasting model output into a locked page, because that paste has no trailer. Teams that publish legally binding terms, security advisories, or incident reports should keep those documents outside generation.
Skip this approach if your documentation is a single README maintained by one person without release trains. Skip it if counsel must approve every public sentence, because the map is an engineering control, not legal review. Skip it if you cannot identify generator commits, since the checker would then block legitimate human maintenance. In those settings, write the pages directly and spend review time on the product rather than on authority metadata.
The map also does not score prose quality, factual density, or example correctness against a style guide. Pair it with executable example tests if your pages contain commands, because this article does not replace that harness. Lifecycle labels can be wrong, and a wrong GA label will freeze pages that should still be draftable. Review the catalog on the same cadence you review feature flags, or the checker will enforce yesterday's product.
What this workflow refuses to automate
The generator may refresh parameter tables after an OpenAPI change on a GA page, because those tables remain rebuildable. It may not invent a deprecation date, a supported-runtime list, or a safe-to-ignore judgment about a breaking change. Those sentences require a named human, even when a model could produce fluent language in seconds. Fluency is not authority, and the merge gate should not confuse the two ideas during review.
If you already have a free server for documentation jobs, run the checker on a GA branch before widening generation. Expanding draft scope without a lifecycle lock recreates the assumption problem that agent tutorials keep rediscovering. Keep experimental pages cheap to regenerate on every catalog change, and keep GA support text expensive to change. That split between cheap experimental drafts and expensive GA support text is the entire method.
Top comments (0)