Generated reference pages stay trustworthy when every example cites a fixture hash and every date cell stays empty. Models may restate request fields, status codes, and recorded payloads that a bill of materials can name. Humans must still own support windows, deprecation calendars, incident severity, and any sentence that names a customer. The workflow below is a proposed local pipeline, not a report of production incident rates or vendor benchmarks.
Why date cells are a different failure class
Schema restatement errors are usually local, because a reviewer can open the OpenAPI file and compare field names. Calendar language is worse, because a fluent sentence can invent a sunset date that no repository file ever stated. SLO numbers create the same trap, since a round percentage looks precise even when no probe, budget, or contract exists. Treat those cells as frozen blanks rather than as creative writing space for a model.
A documentation bill of materials, here called a Doc BOM, makes the freeze mechanical instead of cultural. Each generated block lists input paths and SHA-256 prefixes, then refuses to emit prose outside those inputs. Continuous integration fails when a date-like token appears outside a human stub, which keeps review time on real product decisions. Label this method as a proposal until your own fixtures and schemas are the only inputs in the BOM.
What the model may draft
The allowed surface is narrow and checkable against files that already exist in the repository. Parameter tables may list names, types, and required flags copied from a JSON Schema fragment. Example blocks may wrap fixture bytes in fenced JSON, with the content hash sitting in an HTML comment. Status catalogs may list codes that appear in test node identifiers, not codes that merely sound complete. Headings may repeat schema titles, but they may not ask predictive questions about how long a version will last.
What a human must own
Leave the following regions tagged and empty after the generator finishes its pass. Support windows, including any month, quarter, or year that implies a promise, stay human-authored. Upgrade advice, migration cost, and claims that a release is safe to skip stay human-authored. Security impact, incident severity, and named-customer anecdotes stay human-authored as well. If a table cell would answer when something ends, a person fills that cell after generation, not during it.
When you want a model to fill only BOM-covered restatements, a coding assistant with free model access and a free server option can host the generator without a spare local GPU. Disclosure: This article was prepared as part of MonkeyCode's product outreach. MonkeyCode matters here only as one place to run the same script and the same lint; the ownership rules do not depend on that product.
Artifact: a Doc BOM generator and a date-cell linter
The scripts below are labeled examples you can run with Python 3.11 or newer on a laptop. They read a tiny schema fragment plus a fixtures directory, then write Markdown that carries a YAML BOM in an HTML comment. A second command scans that Markdown and fails on calendar tokens, SLO-shaped percentages, and customer-shaped proper nouns outside human stubs. This is not a claim about latency, model quality, hardware, or token quotas.
Step 1: Keep a minimal schema fragment and two fixtures
Create a working directory that holds only inputs the generator is allowed to see. The schema fragment below is a teaching stand-in, not a public API you should copy into production docs. The fixtures are recorded bytes, which means they describe what happened in a test run, not what you will support next year.
mkdir -p docbom/fixtures
cat > docbom/schema.fragment.json << 'EOF'
{
"title": "ListWidgets",
"type": "object",
"required": ["limit"],
"properties": {
"limit": {"type": "integer", "minimum": 1, "maximum": 100},
"cursor": {"type": "string"}
}
}
EOF
cat > docbom/fixtures/list_widgets_200.json << 'EOF'
{"items": [{"id": "w_01", "name": "alpha"}], "next_cursor": null}
EOF
cat > docbom/fixtures/list_widgets_400.json << 'EOF'
{"error": "limit_out_of_range", "field": "limit"}
EOF
printf '%s\n' \
'tests/test_widgets.py::test_list_widgets_ok[200]' \
'tests/test_widgets.py::test_list_widgets_rejects_limit[400]' \
> docbom/pytest-nodeids.txt
Those four files are the entire evidence set for later generation. If a later sentence cannot point at one of them, it does not belong in a model-drafted block. Keep marketing copy, legal terms, and runbooks out of this directory so the hash list stays small and reviewable.
Step 2: Generate Markdown that only restates hashed inputs
Save the generator as docbom/generate_doc_bom.py. It hashes each input, emits a parameter table, wraps fixtures in fences, and prints empty human cells for every support date. It does not call a network model, which keeps the ownership rule testable even when you later add a drafting step.
#!/usr/bin/env python3
"""Proposed Doc BOM generator. Unexecuted until you run it on local files."""
from __future__ import annotations
import argparse
import hashlib
import json
from pathlib import Path
HUMAN = "<!-- HUMAN_STUB: fill only in a follow-up commit -->"
def sha256_prefix(path: Path, n: int = 12) -> str:
digest = hashlib.sha256(path.read_bytes()).hexdigest()
return digest[:n]
def load_schema(path: Path) -> dict:
return json.loads(path.read_text(encoding="utf-8"))
def render_param_table(schema: dict) -> list[str]:
required = set(schema.get("required", []))
rows = ["| Name | Type | Required | Source |
| --- | --- | --- | --- |"]
properties = schema.get("properties", {})
for name, spec in properties.items():
rows.append(
f"| `{name}` | `{spec.get('type', 'unknown')}` | "
f"{'yes' if name in required else 'no'} | schema |"
)
return rows
def render_fixture_block(path: Path) -> list[str]:
digest = sha256_prefix(path)
body = path.read_text(encoding="utf-8").rstrip()
return [
f"### Example from `{path.name}`",
f"<!-- bom_hash: {digest} src: {path.as_posix()} -->",
"```
json",
body,
"
```",
"",
]
def main() -> None:
parser = argparse.ArgumentParser()
parser.add_argument("--schema", type=Path, required=True)
parser.add_argument("--fixtures", type=Path, required=True)
parser.add_argument("--nodeids", type=Path, required=True)
parser.add_argument("--out", type=Path, required=True)
args = parser.parse_args()
schema = load_schema(args.schema)
fixture_paths = sorted(args.fixtures.glob("*.json"))
bom_lines = [
f"- schema: `{args.schema}` sha256={sha256_prefix(args.schema)}",
f"- nodeids: `{args.nodeids}` sha256={sha256_prefix(args.nodeids)}",
]
for path in fixture_paths:
bom_lines.append(f"- fixture: `{path}` sha256={sha256_prefix(path)}")
parts: list[str] = [
f"# {schema.get('title', 'API fragment')}",
"",
"<!-- doc_bom",
*bom_lines,
"-->",
"",
"## Observed query parameters",
"",
*render_param_table(schema),
"",
"## Recorded responses",
"",
]
for path in fixture_paths:
parts.extend(render_fixture_block(path))
node_ids = [
line.strip()
for line in args.nodeids.read_text(encoding="utf-8").splitlines()
if line.strip()
]
parts.extend(["## Status codes named by tests", ""])
for node_id in node_ids:
code = node_id.rsplit("[", 1)[-1].rstrip("]") if "[" in node_id else "unknown"
parts.append(f"- `{code}` from `{node_id}`")
parts.extend(
[
"",
"## Compatibility matrix (dates are human-owned)",
"",
"| Surface | First observed in tests | Supported until | Notes |",
"| --- | --- | --- | --- |",
f"| `{schema.get('title')}` | see nodeids file | {HUMAN} | {HUMAN} |",
"",
"## Support window",
"",
HUMAN,
"",
]
)
args.out.write_text("\n".join(parts) + "\n", encoding="utf-8")
print(f"wrote {args.out}")
if __name__ == "__main__":
main()
Run the generator against the files from Step 1 and keep the output in version control beside the inputs.
python3 docbom/generate_doc_bom.py \
--schema docbom/schema.fragment.json \
--fixtures docbom/fixtures \
--nodeids docbom/pytest-nodeids.txt \
--out docbom/list_widgets.md
Open list_widgets.md and confirm that example fences match fixture bytes exactly. Confirm also that the compatibility matrix contains the HUMAN_STUB marker rather than a guessed quarter or year. If a later drafting model is allowed to touch this file, restrict its write scope to sections that already contain bom_hash comments.
Step 3: Lint for date cells, SLO tokens, and customer-shaped names
Save the linter as docbom/lint_doc_bom.py. It is deliberately boring: a small set of regular expressions plus a rule that human stubs may remain empty. Boring is useful here, because a clever classifier often starts negotiating with fluent but ungrounded sentences.
#!/usr/bin/env python3
"""Fail generated docs that invent calendars, SLOs, or customer names."""
from __future__ import annotations
import argparse
import re
import sys
from pathlib import Path
HUMAN_MARK = "HUMAN_STUB"
FORBIDDEN = [
("calendar", re.compile(r"\b(?:20\d{2}|Q[1-4]|January|February|March|April|May|June|July|August|September|October|November|December)\b")),
("slo", re.compile(r"\b(?:SLA|SLO|99\.\d+%|five nines|uptime guarantee)\b", re.I)),
("promise", re.compile(r"\b(?:we will support|supported until|safe to skip|no breaking changes)\b", re.I)),
("customer", re.compile(r"\b(?:Acme|customer X|Fortune 500)\b", re.I)),
]
def strip_human_stubs(text: str) -> str:
return re.sub(
r"<!-- HUMAN_STUB:.*?-->",
" ",
text,
flags=re.S,
)
def main() -> int:
parser = argparse.ArgumentParser()
parser.add_argument("markdown", type=Path)
args = parser.parse_args()
raw = args.markdown.read_text(encoding="utf-8")
if "doc_bom" not in raw:
print("missing doc_bom comment; refuse to lint unmarked files", file=sys.stderr)
return 2
scanned = strip_human_stubs(raw)
failures = []
for label, pattern in FORBIDDEN:
for match in pattern.finditer(scanned):
failures.append(f"{label}: {match.group(0)!r} at {match.start()}")
if HUMAN_MARK not in raw:
failures.append("compatibility or support stub is missing")
if failures:
print("lint failed:")
for item in failures:
print(f" - {item}")
return 1
print("lint passed")
return 0
if __name__ == "__main__":
raise SystemExit(main())
python3 docbom/lint_doc_bom.py docbom/list_widgets.md
A passing lint means the generated file still contains empty human cells and no forbidden tokens outside those cells. It does not mean the API is correct, complete, or fit for a customer contract. Add fixture names to the forbidden list if your recorded payloads themselves contain demo company names that should never leak into public docs.
Step 4: Wire both commands into CI as a closed gate
A documentation job should regenerate the Markdown and then lint the result, rather than trusting a previously committed draft. The shell fragment below is a proposed ci step, not a full workflow product. Exit nonzero when either command fails, including a missing BOM comment.
set -euo pipefail
python3 docbom/generate_doc_bom.py \
--schema docbom/schema.fragment.json \
--fixtures docbom/fixtures \
--nodeids docbom/pytest-nodeids.txt \
--out docbom/list_widgets.md
git diff --exit-code -- docbom/list_widgets.md
python3 docbom/lint_doc_bom.py docbom/list_widgets.md
git diff --exit-code catches a silent drift between fixtures and the committed page. If you later allow a model to rewrite example commentary, keep that rewrite in a separate path that still cannot edit the compatibility matrix. Date cells remain empty until a human commit removes a stub marker and adds a reviewed sentence.
Step 5: Fill human stubs in a follow-up commit that cites a decision record
After the generator and linter pass, a person opens the support section and writes dates only when a decision record exists. That record can be an internal RFC, a changelog entry with an owner, or a ticket that states who will answer customer mail when the window ends. Do not let the drafting model complete those sentences just because nearby example JSON looks finished. The second commit should change only HUMAN_STUB regions, which makes review of promises cheaper than review of the whole page.
Limitations
The BOM hashes bytes, not meaning, so a fixture that records a bug will be restated with perfect confidence. The linter is pattern-based, so a model can still smuggle a promise through indirect wording that avoids years, quarters, and the token SLA. Node identifiers encode only tests you already wrote, which means untested error paths stay absent rather than honestly marked unknown. Empty date cells can look like unfinished docs to readers unless you publish a short note that blank means unpromised, not unlimited.
This pipeline also assumes JSON fixtures and a single schema fragment. Binary payloads, streaming APIs, and prose-heavy tutorials need different evidence objects than a twelve-character hash comment. If your public docs mix conceptual guides with reference tables, run the generator only on the reference side and keep the guide in a human-owned tree. Do not treat a green lint as a substitute for legal review of warranty, privacy, or export language.
Who should not use this approach
Skip this workflow if you lack recorded fixtures and would be hashing prompts instead of responses. Skip it for status pages, incident reports, and pricing pages, where almost every sentence is a human commitment. Skip it when a model is expected to invent onboarding narrative, because narrative has no BOM input that a hash can police. Skip it if your review culture already forbids generated docs entirely and you have no need for restated tables.
Teams that sell compliance language should also stay away from model-filled date cells, even with this lint in place. A blank cell is safer than a fluent quarter, but a later editor can still paste a date without updating the decision record. If you cannot require a follow-up human commit, the generator should not run in that repository at all.
A closing check against the freeze
Re-read the generated page and ask one question of each paragraph: which hashed file would falsify this sentence today. If the answer is a schema, a fixture, or a test node id, the model may keep the draft. If the answer is a calendar, a customer, or a support promise, leave the cell blank and wait for a person. If you try the scripts on a public reference repository, keep those human stubs empty until someone who owns the window writes the dates.
Top comments (0)