Config documentation fails most often when a generated catalog silently absorbs production promises that nobody reviewed. Parser extraction can list keys, types, and source lines with high reliability across ordinary Python settings modules. Default values, secret classification, and restart requirements change operational risk, so those claims must stay human-signed. This article describes a two-file workflow that keeps extracted facts and signed constraints in separate artifacts.
The practical split is simple, and it survives model-assisted drafting without pretending the model reviewed production behavior. A compiler reads the settings model and emits a catalog of keys that exist in code today. A second file, owned by a reviewer, attaches operational meaning that source code cannot honestly assert. Rendering then joins both files and refuses to publish any key that still lacks a signature.
Why a single generated page is the wrong artifact
Most config pages collapse four different claim types into one Markdown table that looks authoritative. The table usually mixes parser-visible names with default values copied from development, plus a sentence about restart behavior that nobody verified. Readers treat that mixture as a contract, especially when the page sits next to a public README or an internal runbook. The failure is not that generation exists; the failure is that unsigned operational claims ship in the same lane as extracted names.
A second failure mode appears when a model rewrites prose around the table and quietly restates defaults. The prose sounds careful, so reviewers skim. The catalog still lists SESSION_TTL_SECONDS, but the paragraph now implies a production value and a no-downtime reload that the process does not implement. Downstream on-call pages inherit that implication. The fix is not a better prompt. The fix is a publish gate that cannot render unsigned fields.
What a model may draft
A model, or any mechanical extractor, may draft only claims that are reconstructible from the current tree without talking to production. That set is smaller than a typical README suggests, and naming it explicitly keeps later steps honest. The following items belong in the generated catalog and nowhere else.
- Key identity, including the environment variable name and the settings-class attribute that binds it. Source lines and the enclosing class name should travel with the key so reviewers can jump to the definition without rereading the whole module.
- Declared type and optionality, taken from annotations and parser metadata rather than from guessed examples. Nested models should flatten to dotted paths so later overlays can address one field at a time.
- Presence of a default in code, recorded as a boolean, without copying the default value into the published table. Copying the value turns a development convenience into an implied production contract.
- Stub usage notes that describe how a caller supplies the key, not what value operations currently runs. Those stubs remain drafts until a reviewer accepts or replaces them.
The catalog is allowed to be boring. Boring catalogs diff cleanly, and clean diffs are the actual review surface for documentation that tracks a moving settings module.
What a human must own
Several claims look like documentation but are really operational policy. A model can propose wording; it cannot originate the policy. Keep these fields out of the extractor and require a signed overlay before render.
- Secret classification, including whether the value may appear in logs, crash reports, or support bundles. Code comments are not a classification decision, because comments drift and often describe intent from an earlier threat model.
- Effective production default, including “unset means refuse to boot” when that is the real contract. Development defaults frequently exist only so tests start, and publishing them trains operators to copy unsafe values.
- Restart and reload semantics, including whether a change requires a process restart, a rolling deploy, or a hot reload that the binary does not have. This is a runtime property, not a parser property.
- Compatibility window for renames and removals, including the last release that still accepted the old key. Support windows are product decisions and must not be inferred from a single commit.
If a key is new and the overlay has no row, the renderer must fail. Silence is not an implicit signature.
Artifact: catalog JSON, overlay YAML, and a fail-closed renderer
The original artifact is a three-part kit you can run against a small Pydantic settings module. Label the snippets as a worked example, not as production metrics from a live fleet. Replace the sample fields with your own module before you adopt the gate.
Example settings module (app/settings.py):
# Example only. Not a production settings file.
from pydantic import Field
from pydantic_settings import BaseSettings, SettingsConfigDict
class AppSettings(BaseSettings):
model_config = SettingsConfigDict(env_prefix="APP_", extra="forbid")
database_url: str
session_ttl_seconds: int = Field(default=3600)
log_level: str = Field(default="INFO")
feature_rollout_percent: int = Field(default=0)
Extractor (tools/extract_config_catalog.py):
#!/usr/bin/env python3
"""Extract config key identity from a Pydantic settings class.
Does not copy default values into the published catalog.
"""
from __future__ import annotations
import json
from pathlib import Path
from typing import Any, get_args, get_origin, Union
from pydantic_settings import BaseSettings
def _type_name(annotation: Any) -> str:
origin = get_origin(annotation)
if origin is Union:
args = [a for a in get_args(annotation) if a is not type(None)]
return " | ".join(_type_name(a) for a in args) + " | none"
return getattr(annotation, "__name__", str(annotation))
def extract(settings_cls: type[BaseSettings]) -> dict[str, Any]:
prefix = settings_cls.model_config.get("env_prefix") or ""
rows = []
for name, field in settings_cls.model_fields.items():
rows.append(
{
"attribute": name,
"env_key": f"{prefix}{name.upper()}",
"declared_type": _type_name(field.annotation),
"optional": field.is_required() is False,
"has_default_in_code": field.default is not None
or field.default_factory is not None,
"source": f"{settings_cls.__module__}.{settings_cls.__name__}",
}
)
rows.sort(key=lambda r: r["env_key"])
return {"settings_class": settings_cls.__name__, "keys": rows}
if __name__ == "__main__":
from app.settings import AppSettings
catalog = extract(AppSettings)
Path("docs/generated/config-catalog.json").write_text(
json.dumps(catalog, indent=2) + "\n", encoding="utf-8"
)
print(f"wrote {len(catalog['keys'])} keys")
Overlay schema (docs/signed/config-overlay.example.yaml):
# Human-owned. Do not generate values for these fields.
keys:
APP_DATABASE_URL:
secret_class: secret # secret | restricted | public
production_default: required-unset
restart: process-restart # process-restart | rolling-deploy | hot-reload | unknown
compatible_through: "unspecified"
usage_notes: "DSN for the primary datastore. Never log the password segment."
APP_SESSION_TTL_SECONDS:
secret_class: public
production_default: "review-required"
restart: process-restart
compatible_through: "unspecified"
usage_notes: "Idle session lifetime. Confirm unit is seconds before publishing."
Fail-closed renderer (tools/render_config_docs.py):
#!/usr/bin/env python3
from __future__ import annotations
import json
import sys
from pathlib import Path
import yaml
REQUIRED = ("secret_class", "production_default", "restart", "compatible_through")
SECRET_OK = {"secret", "restricted", "public"}
RESTART_OK = {"process-restart", "rolling-deploy", "hot-reload", "unknown"}
def main() -> int:
catalog = json.loads(Path("docs/generated/config-catalog.json").read_text())
overlay = yaml.safe_load(Path("docs/signed/config-overlay.yaml").read_text())
signed = overlay.get("keys") or {}
missing = []
lines = [
"# Configuration reference",
"",
"Generated keys come from the settings model. Operational columns",
"come from `docs/signed/config-overlay.yaml` and are reviewer-owned.",
"",
"| Env key | Type | Secret class | Production default | Restart |",
"|---|---|---|---|---|",
]
for row in catalog["keys"]:
key = row["env_key"]
entry = signed.get(key)
if not entry:
missing.append(f"{key}: overlay row missing")
continue
for field in REQUIRED:
if not entry.get(field) or entry.get(field) in {"review-required", "unspecified", "unknown"}:
missing.append(f"{key}: {field} is unsigned")
if entry.get("secret_class") not in SECRET_OK:
missing.append(f"{key}: invalid secret_class")
if entry.get("restart") not in RESTART_OK:
missing.append(f"{key}: invalid restart")
lines.append(
f"| `{key}` | `{row['declared_type']}` | {entry.get('secret_class', '')} "
f"| {entry.get('production_default', '')} | {entry.get('restart', '')} |"
)
if missing:
sys.stderr.write("unsigned config claims:\n- " + "\n- ".join(missing) + "\n")
return 1
Path("docs/config-reference.md").write_text("\n".join(lines) + "\n")
return 0
if __name__ == "__main__":
raise SystemExit(main())
Treat unknown, unspecified, and review-required as failing values in continuous integration. They are useful during drafting and poisonous in a published page. The renderer above fails closed on purpose so a partial overlay cannot leak into the branch that readers consume.
Numbered workflow
Follow the sequence in order. Skipping the overlay step is how unsigned defaults re-enter the page.
- Point the extractor at one settings class and write
docs/generated/config-catalog.jsonin CI, not by hand. Commit the catalog if your review culture prefers generated fixtures; otherwise keep it as a build output and store the overlay alone. Either choice works if the renderer still requires a complete overlay before Markdown exists. - Diff the new catalog against the previous successful catalog and open overlay stubs only for added or renamed keys. Do not copy default literals from the model into those stubs. A stub that contains
review-requiredis a task, not documentation. - Fill secret class, production default, restart semantics, and compatibility window in a human review, using runbooks or deploy charts as primary sources. If the reviewer cannot name a restart mode, leave the row unsigned and block the render rather than guessing
hot-reload. - Optionally ask a model to draft
usage_notesfrom the catalog identity fields after the overlay exists. Keep those notes out of the gate’s required set until a reviewer accepts them. Disclosure: This article was prepared as part of MonkeyCode's product outreach. MonkeyCode’s free model access and free server option can host the extractor and a drafting pass without placing that workspace on a production runner. - Render
docs/config-reference.mdonly when the overlay covers every catalog key with signed values. Fail the job when keys are added in code and missing in the overlay, or when a signed field still holds a draft token. - Add a unit test that introduces a throwaway settings field and asserts the renderer exits nonzero without a matching overlay row. That test is the contract for the workflow; the Markdown file is only an output.
A compact command sequence for local checks looks like the following. Adjust paths to match the module you actually maintain.
python tools/extract_config_catalog.py
python tools/render_config_docs.py
test $? -eq 0 || echo "overlay is incomplete; do not publish"
If you keep catalogs in git, a second check should fail when the working tree catalog does not match a fresh extraction. That prevents a reviewer from signing yesterday’s key list while today’s module already renamed a field.
Decision table for publish-time claims
Use this table during review instead of debating whether a paragraph “sounds right.” Each row names a source of truth and a publish rule. If a claim has no row, do not publish it.
| Claim | Source of truth | Model may draft? | Publish rule |
|---|---|---|---|
| Env key name | Parser / settings model | Yes, via extractor | Must match catalog exactly |
| Declared type | Annotations | Yes, via extractor | Must match catalog exactly |
| Has a code default | Field metadata | Yes, as boolean only | Do not print the literal |
| Secret class | Threat model + data policy | Wording only | Reviewer-signed enum |
| Production default | Deploy config / runtime | No | Reviewer-signed; required-unset allowed |
| Restart mode | Process architecture | No | Reviewer-signed enum |
| Compatibility window | Release policy | No | Reviewer-signed; dates need a human |
| Usage notes | Catalog + overlay | Yes, as draft prose | Optional after signature |
The table is the artifact you hand to a new reviewer. It is more useful than a style guide because it tells the renderer what to reject.
Limitations and who should not use this
The extractor only sees types and field names that Pydantic already knows. Dynamic keys registered at boot, per-tenant overrides, and flags injected by a sidecar will not appear, and the overlay cannot honestly sign their absence. Teams that mutate os.environ in middleware need a second inventory, not a richer prompt. Nested secrets that share one DSN string also need a human classification even when the catalog lists a single key.
Do not use this workflow as a substitute for a secrets scanner or for access-control review. Secret class in the overlay is a documentation label, not enforcement. Do not use it for public legal SLAs, uptime promises, or retention periods, because those claims require counsel and operations data that neither the parser nor a drafting model possesses. Skip the approach entirely if no reviewer will own the overlay; an unsigned overlay that still renders is worse than a short, obviously incomplete README.
Generated usage notes also drift when the overlay changes restart mode or secret class. Re-run drafting after overlay edits, then re-read the notes for implied defaults. If a sentence restates a production value, move that value back into the signed column and delete it from prose. The page should remain useful if every drafting tool is removed, because the catalog and overlay are ordinary files with an ordinary publish gate.
A free drafting workspace is optional. The extractor, overlay, and renderer are the method. If you try the drafting pass on MonkeyCode’s free server option, keep the overlay in the same review as the settings change, and do not treat model prose as a signature.
Top comments (0)