Configuration reference pages fail when generated text claims a default is safe, a key is public, or a restart is optional without evidence. A compiler can extract names, types, and literal defaults from a settings class, then refuse secret-shaped keys until a human signs them. Models may draft purpose sentences for non-secret keys, but they cannot own blast radius, rotation policy, or production restart rules. This article presents a small Python catalog compiler, a decision table, and a review gate you can run before any README merge.
Most config docs rot because the published table is a prose rewrite of source, not a projection of source. When a field is renamed in code, the README still lists the old key and an unverified default. When a secret-shaped variable ships a sample value, generators happily copy that value into public markdown. The useful split is mechanical extraction for names and types, plus a human signature for operational meaning.
Why unsigned config copy is a documentation bug
Teams often ask a chat model to write the configuration section from a repository snapshot or a pasted class. The model will usually emit a tidy table, complete with purpose text, example values, and advice about changing defaults. None of those three extras are compileable facts unless your settings module encodes them as reviewed annotations. Purpose, restart cost, and safe-to-change claims are risk copy, and they belong in a signed sidecar, not in raw model output.
Clear comments in source do not repair this gap, because comments are not a publication contract. A comment can say a port is optional while production still crashes without it, and no test will notice. A catalog compiler treats field names, annotation types, and literal defaults as the only compile lane. Everything else stays empty until a human writes it, or a test fails the build.
What a model may draft versus what a human must own
For keys the compiler marks public, a model may propose a one-sentence purpose draft from the field name and type only. That draft is only a suggestion stored in purpose_draft, and it never becomes the published purpose column. The model may also rephrase a human-signed purpose for length, provided a diff review treats the rewrite as unsigned again. The model must not invent defaults, mark a key non-secret, or claim that a process restart is unnecessary.
Humans own four columns that change incident behavior when they are wrong. purpose states what production actually uses the key for, in operator language rather than identifier English. restart_required is a boolean the on-call rotation can trust, not a guess from a field name. blast_radius names which clients, jobs, or regions break if the value is wrong, and rotation_notes records whether a secret can rotate without downtime.
Decision table
| Catalog field | Source of truth | Model allowed? | Publish rule |
|---|---|---|---|
name, type_name, source_line
|
Settings class AST | No drafting needed | Publish only if compile succeeds |
default_repr |
Literal default, redacted when secret-shaped | Never rewrite | Block merge if a secret default is a non-empty literal |
secret_suspect |
Name heuristic plus annotation hints | Never override | Human may confirm, never relax without review |
purpose_draft |
Optional model text over public keys only | Yes, public keys only | Never copy into README |
purpose |
Human signature file | Rephrase only after re-sign | Required for every public key |
restart_required |
Human signature file | No | Required for every key |
blast_radius |
Human signature file | No | Required for every key |
rotation_notes |
Human signature file | No | Required when secret_suspect is true |
Workflow
- Freeze one settings module as the only facts file for names, types, and literal defaults.
- Compile
config_catalog.jsonwith secret-shaped defaults redacted and unsigned operational columns left empty. - Optionally send public-key rows, never secret defaults, to a model that fills
purpose_draftonly. - Record human signatures in
config_signatures.yamlfor purpose, restart, blast radius, and rotation. - Render README markdown from the join of catalog plus signatures, and fail CI when any required cell is empty.
The order matters because a model that sees a raw settings file can quote a sample password as if it were documentation. Keep the compiler between source and any drafting prompt. Keep the signature file in version control next to the catalog, not inside chat history. Treat a regenerated catalog that drops a key as a breaking docs change, the same way you would treat a removed environment variable.
Worked example: a settings class the compiler can read
The following module is a labeled example, not a claim about any production codebase. It mixes public networking fields with secret-shaped credentials so the redaction path is testable. Defaults that look helpful in local development are exactly the values that leak when a model writes a configuration chapter from source.
# settings_example.py — labeled example for the compiler, not production advice.
from dataclasses import dataclass, field
from typing import Optional
@dataclass
class ServiceSettings:
bind_host: str = "0.0.0.0"
bind_port: int = 8080
log_level: str = "INFO"
request_timeout_ms: int = 2500
enable_access_log: bool = True
database_url: str = "postgres://localhost:5432/app"
redis_url: Optional[str] = None
api_token: str = "changeme"
signing_secret: str = ""
smtp_password: Optional[str] = None
feature_batch_size: int = field(default=100)
The catalog compiler
The compiler walks annotated dataclass fields, records line numbers, and applies a conservative secret heuristic. It never prints a non-empty default for a secret-shaped name. It writes JSON that CI can diff, which is more reviewable than a regenerated markdown table with silent column drift. Run it as a command, then keep the JSON artifact in the documentation folder.
# compile_config_catalog.py
from __future__ import annotations
import ast
import json
import re
import sys
from pathlib import Path
from typing import Any
SECRET_RE = re.compile(
r"(secret|token|password|passwd|credential|private_key|api_key)",
re.I,
)
def _is_secret(name: str) -> bool:
return bool(SECRET_RE.search(name))
def _literal_repr(node: ast.AST | None) -> str | None:
if node is None:
return None
try:
value = ast.literal_eval(node)
except Exception:
return "<non-literal>"
return json.dumps(value)
def compile_dataclass_catalog(source: str, filename: str) -> list[dict[str, Any]]:
tree = ast.parse(source, filename=filename)
rows: list[dict[str, Any]] = []
for node in tree.body:
if not isinstance(node, ast.ClassDef):
continue
for item in node.body:
if not isinstance(item, ast.AnnAssign) or not isinstance(item.target, ast.Name):
continue
name = item.target.id
type_name = ast.unparse(item.annotation) if item.annotation else "Any"
secret = _is_secret(name)
raw_default = _literal_repr(item.value)
if secret and raw_default not in (None, "\"\"", "null"):
default_repr = "<redacted-nonempty-secret-default>"
status = "blocked_secret_default"
elif secret:
default_repr = "<redacted>"
status = "needs_human_signature"
else:
default_repr = raw_default
status = "needs_human_signature"
rows.append(
{
"class_name": node.name,
"name": name,
"type_name": type_name,
"default_repr": default_repr,
"source_line": item.lineno,
"secret_suspect": secret,
"status": status,
"purpose_draft": "",
"purpose": "",
"restart_required": None,
"blast_radius": "",
"rotation_notes": "",
}
)
return rows
def main() -> None:
path = Path(sys.argv[1])
rows = compile_dataclass_catalog(path.read_text(encoding="utf-8"), path.name)
Path(sys.argv[2]).write_text(json.dumps(rows, indent=2) + "\n", encoding="utf-8")
blocked = [r["name"] for r in rows if r["status"] == "blocked_secret_default"]
if blocked:
raise SystemExit(f"blocked nonempty secret defaults: {blocked}")
if __name__ == "__main__":
main()
python compile_config_catalog.py settings_example.py config_catalog.json
On this example the command should exit nonzero because api_token carries a nonempty literal default. That failure is the documentation bug surfacing before a README exists. After the sample default is removed from source, the compiler emits rows that still lack purpose and restart data. Those empty cells are intentional, and they are what the signature file is for.
Human signature file and join renderer
Signatures live in YAML so reviewers can comment on operational claims without re-litigating types. The join step copies compile-lane fields from JSON and operational fields from YAML, then refuses to render when a required key is missing. A model may fill purpose_draft in a scratch file; the renderer never reads that scratch file.
# config_signatures.yaml — humans own every value in this file.
bind_host:
purpose: "Address the HTTP process binds for inbound traffic."
restart_required: true
blast_radius: "All inbound HTTP clients for this process."
api_token:
purpose: "Bearer token accepted by internal service callers."
restart_required: true
blast_radius: "Any client that caches the previous token."
rotation_notes: "Rotate in the secrets manager, then bounce every replica."
# render_config_docs.py
from __future__ import annotations
import json
import sys
from pathlib import Path
import yaml
REQUIRED_PUBLIC = ("purpose", "restart_required", "blast_radius")
REQUIRED_SECRET = REQUIRED_PUBLIC + ("rotation_notes",)
def render(catalog_path: Path, signatures_path: Path) -> str:
rows = json.loads(catalog_path.read_text(encoding="utf-8"))
signatures = yaml.safe_load(signatures_path.read_text(encoding="utf-8")) or {}
missing: list[str] = []
lines = [
"| Key | Type | Default | Restart | Purpose | Blast radius |",
"| --- | --- | --- | --- | --- | --- |",
]
for row in rows:
if row["status"] == "blocked_secret_default":
missing.append(f"{row['name']}: nonempty secret default still in source")
continue
sig = signatures.get(row["name"], {})
needed = REQUIRED_SECRET if row["secret_suspect"] else REQUIRED_PUBLIC
for field in needed:
if sig.get(field) in (None, ""):
missing.append(f"{row['name']}.{field}")
restart = sig.get("restart_required")
restart_cell = "yes" if restart is True else "no" if restart is False else ""
default_cell = row["default_repr"] if not row["secret_suspect"] else "<redacted>"
lines.append(
f"| `{row['name']}` | `{row['type_name']}` | `{default_cell}` | {restart_cell} | {sig.get('purpose', '')} | {sig.get('blast_radius', '')} |"
)
if missing:
raise SystemExit("unsigned or blocked config docs: " + ", ".join(missing))
return "\n".join(lines) + "\n"
if __name__ == "__main__":
sys.stdout.write(render(Path(sys.argv[1]), Path(sys.argv[2])))
Tests that block unsigned catalogs
A documentation compiler without tests becomes another generator people ignore after the first drift incident. The tests below encode three invariants: secret defaults never publish, every catalog key has a signature row, and extra signature rows do not hide deleted settings. They are ordinary unit tests, and they should run in the same job that builds docs.
# test_config_catalog.py
from pathlib import Path
import pytest
from compile_config_catalog import compile_dataclass_catalog
from render_config_docs import render
EXAMPLE = Path("settings_example.py").read_text(encoding="utf-8")
def test_nonempty_secret_default_is_blocked():
rows = compile_dataclass_catalog(EXAMPLE, "settings_example.py")
token = next(r for r in rows if r["name"] == "api_token")
assert token["status"] == "blocked_secret_default"
assert "changeme" not in token["default_repr"]
def test_public_default_is_visible():
rows = compile_dataclass_catalog(EXAMPLE, "settings_example.py")
port = next(r for r in rows if r["name"] == "bind_port")
assert port["default_repr"] == "8080"
assert port["secret_suspect"] is False
def test_render_fails_until_signatures_exist(tmp_path: Path):
catalog = tmp_path / "catalog.json"
signatures = tmp_path / "sigs.yaml"
# Use a cleaned class so the blocked default does not dominate the assertion.
cleaned = EXAMPLE.replace('api_token: str = "changeme"', 'api_token: str = ""')
rows = compile_dataclass_catalog(cleaned, "settings_example.py")
catalog.write_text(__import__("json").dumps(rows), encoding="utf-8")
signatures.write_text("{}\n", encoding="utf-8")
with pytest.raises(SystemExit):
render(catalog, signatures)
pytest -q test_config_catalog.py
Where a drafting environment belongs
Disclosure: This article was prepared as part of MonkeyCode's product outreach. After the compiler has classified keys and redacted secret defaults, a drafting pass can write purpose_draft sentences for public rows only. MonkeyCode's free model access and free server option can host that narrow pass if the prompt receives catalog JSON without nonempty secret literals. The draft still dies in review unless a human copies a corrected sentence into config_signatures.yaml and the render tests go green.
Do not paste settings_example.py into a prompt while it still contains sample tokens. Do not ask any model whether a restart is required, because that answer is an operational commitment. Keep the free drafting path optional; the compiler and tests already produce a useful catalog when every purpose sentence is written by hand.
Limitations
This workflow only reads annotated dataclass fields in one Python file, and it will miss environment keys assembled at runtime from string concatenation. The secret heuristic is name-based, so database_url stays public even when it embeds a password, which is a real false negative. Literal defaults that call factory functions become <non-literal> and still need a human to describe the effective value. The renderer does not prove that production processes load the same class the docs compiled, so a second service with a forked settings file can drift silently.
The approach also does not replace a secrets manager, a rotation runbook, or an access-review process. It only prevents a documentation page from asserting facts the repository cannot support. If your configuration lives in Helm values, Terraform variables, or JSON Schema, you need a separate extractor; copying this script onto those files will not parse them. Benchmarks, model names, token quotas, and server hardware are out of scope here because they are not required to validate the compile-versus-signature split.
Who should not use this approach
Skip this compiler if your team publishes configuration docs that must include live default passwords for shared staging hosts. Skip it if you want a fully automatic README with no signature file and no failing tests. Skip it if the settings class is generated code that changes every build, because the catalog diff will drown review. Skip it if legal or compliance review requires a dedicated secrets inventory with attested owners, which this JSON file is not.
Teams that already maintain OpenAPI or JSON Schema as the configuration contract should extract from that schema instead of duplicating types in a dataclass walker. The ownership rule stays the same in that setup: compile names and types, sign restart and blast radius. The artifact above is for services whose settings class is still the only honest list of keys. If that class is dishonest, fix the class before generating any table.
Unsigned configuration prose is a release defect, not a writing-style problem. Compile the keys you can prove, redact the defaults you cannot publish, and make humans sign the sentences that would be wrong during an incident.
Top comments (0)