Configuration reference pages stay accurate only when field names, types, and enums are compiled from a reviewed JSON Schema. Default-value rationale, secret-handling rules, and restart constraints are policy statements, so a human must sign those paragraphs. A model may draft the compiler and a first-pass table, but the published reference must match the schema hash. Mixing policy copy into the generated file is the usual path by which incorrect production guidance ships.
This article proposes a documentation-generation split for configuration surfaces, not for endpoint walkthroughs or credential-lifecycle copy. The compile lane owns names, types, enumerations, required flags, and non-secret examples that already exist in a machine-checked schema. The policy lane owns why a default exists, where secrets live, and what restarts after a change. Teams that keep both lanes in one chat transcript cannot later prove which sentence was generated and which sentence was reviewed.
Separate schema facts from operational policy
JSON Schema already answers a narrow set of documentation questions with less ambiguity than free-form prose. A properties map lists field names; type, enum, and required describe the public shape; minLength and similar keywords bound the value space. Those claims can be regenerated on every schema change without inventing product promises. They also fail closed in CI when the generated Markdown drifts from the schema digest.
Operational policy does not live in those keywords, even when a schema includes default. A default integer does not explain whether operators may lower it in production, or whether a rolling restart is required. Secret material must never appear as an example value, and rotation steps are runbooks rather than types. Treating default as permission to write rollout guidance is how generated docs contradict the incident process.
The practical rule is therefore claim-typed rather than file-typed. One Markdown file may still render both tables and policy, but the compiler may write only marked regions whose source is the schema. Human-signed regions stay unwritable to model diffs, even when the surrounding page is regenerated. Path-level locks remain useful, yet they do not stop a model from rewriting a “Security notes” heading inside an otherwise generated page.
Ownership decision matrix
Use the matrix below as a review checklist before any generator runs. Rows are claim classes, not file names, so mixed documents remain auditable.
| Claim class | Source of truth | Allowed writer | CI gate |
|---|---|---|---|
| Field name, JSON type, enum | Reviewed config.schema.json
|
Compiler only | Generated table digest must match schema digest |
| Required versus optional | Schema required array |
Compiler only | Missing required row fails the build |
| Non-secret example values | Reviewed examples/config.valid.json
|
Compiler only | Example file hash is pinned in lockfile |
| Default-value rationale | Human policy review | Signed Markdown | Model hunks in signed regions fail |
| Secret storage and rotation | Security owners | Signed Markdown | Examples matching secret patterns fail |
| Restart, reload, and drain rules | On-call owners | Signed Markdown | Unsigned edits to those headings fail |
| Production numeric ceilings | Capacity review, not schema | Signed Markdown | Compiler must not copy default into policy prose |
The matrix is a proposed control, not a measured production study. Teams should adapt owners to their review process rather than copying titles from this table. If a claim cannot be traced to a schema keyword or a named human owner, it does not belong in the generated table.
Proposed files
Keep the schema, the example document, the compiler, and the signed policy as four separate artifacts. The layout below is a proposal and has not been executed against a live product in this article.
config.schema.json
examples/config.valid.json
docs/generated/config-reference.md
docs/signed/config-operations.md
docs/ownership.yaml
scripts/compile_config_docs.py
scripts/check_docs_ownership.py
tests/test_compile_config_docs.py
.lock/config-docs.lock.json
docs/ownership.yaml records region markers instead of only glob paths. Generated files remain fully writable to the compiler and fully unwritable to unconstrained model patches. Signed files reject any hunk that is not authored by a listed reviewer identity in CI metadata.
# docs/ownership.yaml
version: 1
regions:
- id: config-reference-table
path: docs/generated/config-reference.md
lane: compile
source: config.schema.json
writer: compiler
- id: config-operations-policy
path: docs/signed/config-operations.md
lane: policy
headings:
- "Why these defaults exist"
- "Secret handling"
- "Restart and reload"
writer: human
unwritable_to:
- model
- compiler
lockfile: .lock/config-docs.lock.json
secret_example_deny:
- '(?i)api[_-]?key'
- '(?i)password'
- '(?i)begin (rsa|openssh) private key'
Pin the schema and the valid example by digest so the compiler cannot silently follow an unreviewed edit. The lockfile is the review artifact; chat history is not.
{
"schema_sha256": "REPLACE_AFTER_REVIEW",
"example_sha256": "REPLACE_AFTER_REVIEW",
"generated_sha256": "REPLACE_AFTER_COMPILE"
}
Compiler steps
The numbered flow is intentionally mechanical. Each step either reads a reviewed file or writes a file that CI will re-hash.
- Verify
config.schema.jsonparses as JSON Schema and matches.lock/config-docs.lock.json. - Load
examples/config.valid.jsonand reject the build if any value matchessecret_example_deny. - Walk
propertiesandrequiredto emit one table row per field, including type, enum, and required flag. - Write only
docs/generated/config-reference.md; never opendocs/signed/config-operations.mdfor write. - Recompute the generated-file digest and fail if it does not match the lockfile after a deliberate compile commit.
- Run the ownership checker on the pull request diff and reject model hunks that touch policy headings.
A minimal compiler sketch follows. Treat it as labeled pseudocode for a proposed tool, not as a claim that this exact script shipped.
#!/usr/bin/env python3
"""Compile docs/generated/config-reference.md from a reviewed JSON Schema."""
from __future__ import annotations
import hashlib
import json
import pathlib
import re
import sys
ROOT = pathlib.Path(__file__).resolve().parents[1]
SCHEMA = ROOT / "config.schema.json"
EXAMPLE = ROOT / "examples/config.valid.json"
OUT = ROOT / "docs/generated/config-reference.md"
LOCK = ROOT / ".lock/config-docs.lock.json"
DENY = [
re.compile(r"(?i)api[_-]?key"),
re.compile(r"(?i)password"),
re.compile(r"(?i)begin (rsa|openssh) private key"),
]
def sha256(path: pathlib.Path) -> str:
return hashlib.sha256(path.read_bytes()).hexdigest()
def load_json(path: pathlib.Path) -> dict:
return json.loads(path.read_text(encoding="utf-8"))
def assert_lock(schema: dict) -> None:
lock = load_json(LOCK)
if sha256(SCHEMA) != lock["schema_sha256"]:
raise SystemExit("schema digest does not match lockfile")
if sha256(EXAMPLE) != lock["example_sha256"]:
raise SystemExit("example digest does not match lockfile")
if schema.get("$schema") is None:
raise SystemExit("schema must declare $schema for review traceability")
def reject_secrets(node) -> None:
if isinstance(node, str):
for pat in DENY:
if pat.search(node):
raise SystemExit(f"secret-like example blocked: {pat.pattern}")
elif isinstance(node, dict):
for value in node.values():
reject_secrets(value)
elif isinstance(node, list):
for item in node:
reject_secrets(item)
def rows(schema: dict) -> list[str]:
required = set(schema.get("required", []))
properties = schema.get("properties") or {}
lines = [
"<!-- lane:compile source:config.schema.json -->",
"# Configuration reference",
"",
"This table is compiled from `config.schema.json`. Do not hand-edit it.",
"",
"| Field | Type | Required | Enum | Example |",
"| --- | --- | --- | --- | --- |",
]
example = load_json(EXAMPLE)
for name, spec in sorted(properties.items()):
types = spec.get("type", "")
if isinstance(types, list):
types = ",".join(types)
enum = ", ".join(str(v) for v in spec.get("enum", [])) or "—"
sample = example.get(name, "—")
if isinstance(sample, (dict, list)):
sample = "`json`"
lines.append(
f"| `{name}` | {types or '—'} | {'yes' if name in required else 'no'} | {enum} | {sample} |"
)
lines.append("")
lines.append("Operational defaults, secrets, and restart rules live in `docs/signed/config-operations.md`.")
lines.append("")
return lines
def main() -> int:
schema = load_json(SCHEMA)
assert_lock(schema)
reject_secrets(load_json(EXAMPLE))
OUT.parent.mkdir(parents=True, exist_ok=True)
OUT.write_text("\n".join(rows(schema)), encoding="utf-8")
print(f"wrote {OUT}")
return 0
if __name__ == "__main__":
sys.exit(main())
Human-owned policy stays in a separate file with stable headings. The compiler must not interpolate schema default values into these sections, because a schema default is not an operations promise.
# Configuration operations
## Why these defaults exist
<!-- lane:policy writer:human -->
Write the product reason for each default that operators are tempted to copy.
State whether the value is a local-development convenience or a production floor.
Do not paste schema defaults here without a dated owner signature.
## Secret handling
<!-- lane:policy writer:human -->
Name the secret store, the rotation owner, and the forbid-list for documentation examples.
Never publish tokens, private keys, or session cookies, including in fenced blocks.
## Restart and reload
<!-- lane:policy writer:human -->
State which keys require a process restart, which reload on SIGHUP, and which need a drain.
Record the maximum unsafe window only after on-call review, not after a model draft.
Tests that keep the split honest
A compiler without tests will eventually write policy-shaped sentences into the table footer. The following pytest module is a proposed contract for the sketch above.
# tests/test_compile_config_docs.py
import pathlib
import re
import subprocess
import sys
ROOT = pathlib.Path(__file__).resolve().parents[1]
GEN = ROOT / "docs/generated/config-reference.md"
SIGNED = ROOT / "docs/signed/config-operations.md"
POLICY_HEADINGS = (
"Why these defaults exist",
"Secret handling",
"Restart and reload",
)
def test_compiler_writes_only_generated_path(tmp_path, monkeypatch):
before = SIGNED.read_bytes()
subprocess.check_call([sys.executable, str(ROOT / "scripts/compile_config_docs.py")])
assert SIGNED.read_bytes() == before
text = GEN.read_text(encoding="utf-8")
assert "lane:compile" in text
assert "Secret handling" not in text
def test_signed_file_keeps_policy_headings():
text = SIGNED.read_text(encoding="utf-8")
for heading in POLICY_HEADINGS:
assert heading in text
assert "lane:policy" in text
def test_generated_table_has_required_column():
subprocess.check_call([sys.executable, str(ROOT / "scripts/compile_config_docs.py")])
header = GEN.read_text(encoding="utf-8").splitlines()
table = [line for line in header if line.startswith("| Field")]
assert table, "missing compiled table header"
assert "Required" in table[0]
Add a diff gate that classifies hunks by path and heading rather than by author display name alone. Display names are not an ownership control.
python3 scripts/check_docs_ownership.py --diff origin/main...HEAD --ownership docs/ownership.yaml
test "$(git diff --name-only origin/main...HEAD -- docs/signed)" = "" \
|| echo "signed policy files changed; require a human reviewer label"
A small ownership checker can parse unified diffs and fail closed. The sketch below rejects any added or removed line under a policy heading, regardless of commit message wording.
# scripts/check_docs_ownership.py (proposed)
import argparse
import pathlib
import subprocess
import sys
import yaml
def changed_files(rev: str) -> list[str]:
out = subprocess.check_output(["git", "diff", "--name-only", rev], text=True)
return [line for line in out.splitlines() if line]
def main() -> int:
parser = argparse.ArgumentParser()
parser.add_argument("--diff", required=True)
parser.add_argument("--ownership", required=True)
args = parser.parse_args()
spec = yaml.safe_load(pathlib.Path(args.ownership).read_text(encoding="utf-8"))
blocked = {
region["path"]
for region in spec["regions"]
if region["lane"] == "policy"
}
offenders = sorted(set(changed_files(args.diff)) & blocked)
if offenders:
print("policy-lane files changed:")
print("\n".join(offenders))
return 1
return 0
if __name__ == "__main__":
sys.exit(main())
What a model may draft
A model is useful on the compile side when it writes boring, checkable text. It may draft the compiler, the table template, extra deny-list patterns, and a first-pass schema that a reviewer then pins. It may also propose missing type and enum keywords that tests later prove against examples/config.valid.json. It must not invent restart windows, production ceilings, or secret-store product names.
Disclosure: This article was prepared as part of MonkeyCode's product outreach. MonkeyCode's free model access and free server option can host that draft-and-recompile loop when a team wants a disposable runner for the compiler sketch. The published page still has to match the reviewed schema digest; the hosted draft is not a source of truth. After the model returns a patch, re-run compile_config_docs.py and keep only hunks that the lockfile and ownership checker accept.
Do not ask the model to “make the docs friendlier” across both files in one prompt. Friendliness edits routinely smear policy claims into table captions, which CI will not notice unless captions are also locked. Prefer two prompts with two output paths, then a deterministic compile, then a human signature on the policy file.
Limitations
This pattern assumes configuration is already expressible as JSON Schema, or can be exported to JSON Schema without losing field names. Binary flags in undocumented C structs, and ad hoc environment variables assembled in shell, will not compile into a trustworthy table. If the schema uses unconstrained additionalProperties, the generated reference will under-specify production keys and should not be published as complete.
The digest lock will also fight legitimate schema review if owners rewrite config.schema.json without updating .lock/config-docs.lock.json. That friction is intentional, but it needs a documented unlock procedure. Teams without a reviewer who can distinguish a type change from a policy change should not enable model writes on either file.
Who should skip this approach is equally concrete. Skip it when legal copy, availability commitments, or customer-specific ceilings must appear beside every field, because those sentences cannot be compiled. Skip it when examples must include live credentials, which this deny-list exists to prevent. Skip it for one-off internal notes that will never be regenerated, where a compiler adds ceremony without a second reader.
The core conclusion does not change with a larger model or a hosted runner. Schema-backed tables can be compiled; operational promises must be signed. If a sentence cannot name its source file and its writer lane, it is not ready for the configuration docs set.
Top comments (0)