HTTP examples can be compiled from recorded cassettes because method, path, status, and bodies already exist as traffic. Sunset calendars, OAuth scope meaning, and field-level logging bans do not appear in those recordings, so they remain reviewer-owned copy. Mixing both kinds of text in one generated Markdown file turns a cheap regeneration into an unsigned policy change. The workflow below keeps example blocks on a compile path and keeps sunset and scope language in files a model cannot write.
Why example blocks and policy copy fail on different axes
Cassette-backed examples go stale when routes, headers, or required fields change in the running service under test. Policy copy goes wrong when a date, a permission name, or a retention claim is fluent but unauthorized. Those two failure modes need different gates, because regenerating an example is a correctness fix and regenerating a sunset date is a product decision. Treating both as documentation the model can draft hides the second failure inside a successful-looking pull request.
Recorded traffic also contains secrets, session cookies, and internal hostnames that must never reach public docs. A compiler that copies cassettes blindly will publish credentials faster than it publishes accurate public examples. Redaction therefore belongs in the compile path, not in a later editorial pass that reviewers forget under time pressure.
Ownership matrix for a documentation generation run
Use this table as the artifact that CI enforces, not as a style guide that authors try to remember. Every path under docs/ maps to one owner class before any model run starts. If a file currently mixes classes, split it before the next generation rather than annotating after merge.
| Section | Source of truth | Model may draft? | Human must own | Publish gate |
|---|---|---|---|---|
| Example curl and JSON | HTTP cassette or HAR | Structure yes; invented fields no | Redaction list | Diff must match cassette after redaction |
| Request field names | OpenAPI schema | Yes, from schema only | Behavior promises in descriptions | Schema hash pinned in the pull request |
| Endpoint grouping and ToC | OpenAPI tags | Yes | Tag taxonomy changes | Tag rename requires a reviewer note |
| Sunset and deprecation dates | Product calendar | No | Exact dates and successor routes | File not writable by the draft worker |
| OAuth scope meaning | Security review | No | Which tokens may call which operations | File not writable by the draft worker |
| PII and logging bans | Privacy review | No | Field names that must not be logged | File not writable by the draft worker |
| Delivery guarantees | Contract or SRE review | No | At-least-once versus exactly-once language | File not writable by the draft worker |
Unexecuted proposal: treat any cell marked No as a merge blocker if the draft worker identity appears in git blame for that path. The matrix is the contract; fluent Markdown is not evidence that the contract held.
Numbered workflow
1. Freeze an ownership manifest before generation
Commit docs/ownership.yaml in the same change that introduces generated examples. The manifest is the allowlist for the draft worker, and unsigned paths stay outside that list. Generation that cannot name its write set should not start.
# docs/ownership.yaml
version: 1
draft_root: docs/_generated/examples
human_root: docs/policy
public_base_url: https://api.example.com
redaction:
headers:
- Authorization
- Cookie
- Set-Cookie
- X-Api-Key
body_json_pointers:
- /access_token
- /refresh_token
- /email
- /card/number
rules:
- glob: docs/_generated/examples/**/*.md
owner: compiler
model_prose: wrap_only
- glob: docs/policy/sunset.md
owner: human
model_prose: forbid
- glob: docs/policy/scopes.md
owner: human
model_prose: forbid
- glob: docs/policy/logging.md
owner: human
model_prose: forbid
The compiler reads this file first and refuses to start when a target path has owner: human. That refusal is cheaper than a review comment after a model has rewritten a date.
2. Record cassettes as the only example source
Store sanitized traffic next to tests, not in chat history or paste buffers. Each cassette should name the OpenAPI operationId so the compiler can place the example under the correct heading. One happy-path exchange per operation is enough to start; multi-step flows need several files and a human sequence note.
mkdir -p fixtures/http docs/_generated/examples docs/policy
# Proposal: capture one happy-path exchange per operationId.
# Point the URL at a local recorder, not at production.
curl -sS -D fixtures/http/list_widgets.headers \
-o fixtures/http/list_widgets.body.json \
-H "Accept: application/json" \
http://127.0.0.1:8080/v1/widgets
Label the next block as an unexecuted compiler sketch, not as a production service with measured throughput. Deterministic extraction should produce the curl command and the JSON body. A model, if used at all, may write one wrap sentence that does not add fields, status codes, or hostnames.
# compile_examples.py — proposal / unexecuted sketch
from __future__ import annotations
import json
from pathlib import Path
from yaml import safe_load
ROOT = Path(".")
OWN = safe_load((ROOT / "docs" / "ownership.yaml").read_text())
def redact(obj, pointers):
if not isinstance(obj, dict):
return obj
for pointer in pointers:
keys = [part for part in pointer.split("/") if part]
cur = obj
for key in keys[:-1]:
if not isinstance(cur, dict) or key not in cur:
cur = None
break
cur = cur[key]
if isinstance(cur, dict) and keys[-1] in cur:
cur[keys[-1]] = "[REDACTED]"
return obj
def cassette_to_markdown(operation_id, method, path, status, body, base_url):
pretty = json.dumps(body, indent=2, sort_keys=True)
lines = [
f"## Example: `{operation_id}`",
"",
(
f"Compiled from cassette `{operation_id}`; "
"fields not present in traffic are omitted."
),
"",
"```
bash",
f"curl -sS -X {method} \\",
f" '{base_url}{path}'",
"
```",
"",
f"**Status:** {status}",
"",
"```
json",
pretty,
"
```",
"",
]
return "\n".join(lines)
def main() -> None:
draft_root = ROOT / OWN["draft_root"]
draft_root.mkdir(parents=True, exist_ok=True)
pointers = OWN["redaction"]["body_json_pointers"]
body_path = ROOT / "fixtures/http/list_widgets.body.json"
body = redact(json.loads(body_path.read_text()), pointers)
markdown = cassette_to_markdown(
"list_widgets",
"GET",
"/v1/widgets",
200,
body,
OWN["public_base_url"],
)
(draft_root / "list_widgets.md").write_text(markdown)
if __name__ == "__main__":
main()
The public base URL comes from the reviewed manifest, not from the cassette hostname and not from model output. Internal hosts in recordings are a leak class, not a documentation convenience.
3. Isolate the draft worker from policy files
Run generation on a worker that can write docs/_generated/ and cannot write docs/policy/. Filesystem permissions and branch rules should agree, because a polite prompt is not an access control. Wrap sentences are optional; structure from the cassette is not.
Disclosure: This article was prepared as part of MonkeyCode's product outreach. MonkeyCode's free model access and free server option can host that isolated draft worker when teams want generation off the laptop without granting policy-file permissions. The worker still must not invent sunset dates, scope explanations, or logging bans, and this article does not claim model names, quotas, hardware, duration, or uptime.
# Proposal: run the compiler where the worker can write drafts only.
chmod -R a-w docs/policy
python compile_examples.py
python check_ownership.py
4. Gate the merge on owner class, not on fluency
check_ownership.py should fail the build when a human-owned file changes in a generation commit, or when a generated file contains keys absent from the cassette. A green check means ownership held, not that the Markdown reads smoothly in a browser preview.
# check_ownership.py — proposal / unexecuted sketch
from __future__ import annotations
import json
import subprocess
import sys
from fnmatch import fnmatch
from pathlib import Path
from yaml import safe_load
OWN = safe_load(Path("docs/ownership.yaml").read_text())
FORBIDDEN_IN_EXAMPLES = ("guaranteed", "exactly-once", "deprecated on")
def changed_files() -> list[str]:
diff = subprocess.check_output(
["git", "diff", "--name-only", "origin/main...HEAD"],
text=True,
)
return [line for line in diff.splitlines() if line]
def main() -> int:
changed = changed_files()
errors = []
generated = Path("docs/_generated/examples/list_widgets.md").read_text()
cassette = json.loads(Path("fixtures/http/list_widgets.body.json").read_text())
cassette_keys = set(cassette.keys()) if isinstance(cassette, dict) else set()
for token in FORBIDDEN_IN_EXAMPLES:
if token in generated.lower():
errors.append(f"policy phrase leaked into example: {token}")
for key in ("access_token", "refresh_token"):
if key in generated and "[REDACTED]" not in generated:
errors.append(f"unredacted key visible in generated example: {key}")
for path in changed:
for rule in OWN["rules"]:
if fnmatch(path, rule["glob"]) and rule["owner"] == "human":
errors.append(
f"human-owned path changed in generation commit: {path}"
)
if "estimated_delivery" in generated and "estimated_delivery" not in cassette_keys:
errors.append("generated example invented a field missing from cassette")
for err in errors:
print(err, file=sys.stderr)
return 1 if errors else 0
if __name__ == "__main__":
sys.exit(main())
Wire both scripts into CI so example regeneration cannot land without the ownership check. Path filters keep the job cheap; they should include fixtures, because a cassette change is a documentation change.
# .github/workflows/docs-ownership.yml — proposal
name: docs-ownership
on:
pull_request:
paths:
- "docs/**"
- "fixtures/http/**"
- "compile_examples.py"
- "check_ownership.py"
jobs:
gate:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- uses: actions/setup-python@v5
with:
python-version: "3.12"
- run: pip install pyyaml
- run: python compile_examples.py
- run: python check_ownership.py
5. Reviewer checklist for the human-owned files
Keep the checklist short so it is actually used on ordinary pull requests. Reviewers confirm dates, scope names, and logging bans against primary sources, not against a wrap sentence that sits above a curl block.
- Open
docs/policy/sunset.mdand compare every date to the product calendar issue or change ticket. - Open
docs/policy/scopes.mdand compare every scope string to the authorization server catalog. - Open
docs/policy/logging.mdand confirm each banned field still exists in the current schema. - Reject the pull request if a generated example includes a field that the cassette does not contain.
- Reject the pull request if the draft worker identity appears on a human-owned path in the diff.
What the model may draft, restated as a test plan
A useful test is not whether the paragraph sounds like the rest of the documentation set. A useful test is whether regeneration can change liability without a human-owned file moving. Run the rows below on every documentation pull request that touches examples.
| Test id | Input | Expected result |
|---|---|---|
| T1 | Cassette body lacks estimated_delivery
|
Generated Markdown must not mention estimated_delivery
|
| T2 | Cassette header has Authorization
|
Generated Markdown must not contain the header value |
| T3 | Wrap sentence adds guaranteed next-day shipping | Gate fails; the phrase is policy, not traffic |
| T4 |
docs/policy/sunset.md edited by the draft worker |
Gate fails even if the date looks plausible |
| T5 | Schema adds a required query parameter | Example compile fails until a new cassette is recorded |
Do not skip T3 because the sentence is helpful to newcomers reading the page. Helpful unsigned claims are how example pipelines become support tickets and, later, disputed contract language. T5 exists because schema drift without new traffic is a missing recording, not a prompt-engineering problem.
Limitations
This approach assumes the team already records traffic per operation and can store redacted cassettes in git or an artifact store. Teams without fixtures will be tempted to let a model invent examples, which reintroduces the policy leak this pipeline exists to stop. The ownership manifest does not replace legal review for privacy statements, and a compile gate cannot prove that a sunset date is the date customers were told.
Cassette compilers also lag multi-step flows such as redirects, pagination cursors, and webhook retries. Those flows need several recordings and a human-written sequence narrative; collapsing them into one curl block will document a fantasy happy path. Hostnames in cassettes are often internal, so the compiler must substitute a public base URL from a reviewed config file rather than from model output.
A remote draft worker does not make the ownership gate optional. Isolation of write paths is the control, and wrap-sentence generation is only a helper sitting behind that control. If the helper can push to docs/policy/, the rest of the pipeline is theater.
Who should not use this workflow
Do not use this workflow if public docs are themselves the contract and every sentence needs counsel before publish. Do not use it if cassettes cannot be redacted automatically, because a compiler will copy secrets with perfect fidelity. Do not use it for narrative tutorials whose value is judgment, architecture tradeoffs, or when-not-to-call guidance, which remain human-owned even when examples compile cleanly.
Skip it for throwaway prototypes with no public readers outside the authors. The ownership YAML and CI job are overhead until someone outside the team will treat a generated curl block as advice. Skip it also when the OpenAPI document is unofficial and the recorded traffic is the only spec, because then example compilation has nothing independent to disagree with.
If policy files already sit off the generation path, a draft worker with free model access on a free server can fill cassette-backed example blocks without touching sunset or scope copy. That remains a write-path design choice, and it still requires the gate in check_ownership.py before merge.
Top comments (0)