DEV Community

Avery Lin
Avery Lin

Posted on

Compile Config Catalogs From Settings Classes; Humans Sign Secrets and Restarts

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

  1. Freeze one settings module as the only facts file for names, types, and literal defaults.
  2. Compile config_catalog.json with secret-shaped defaults redacted and unsigned operational columns left empty.
  3. Optionally send public-key rows, never secret defaults, to a model that fills purpose_draft only.
  4. Record human signatures in config_signatures.yaml for purpose, restart, blast radius, and rotation.
  5. 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)
Enter fullscreen mode Exit fullscreen mode

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()
Enter fullscreen mode Exit fullscreen mode
python compile_config_catalog.py settings_example.py config_catalog.json
Enter fullscreen mode Exit fullscreen mode

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."
Enter fullscreen mode Exit fullscreen mode
# 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])))
Enter fullscreen mode Exit fullscreen mode

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)
Enter fullscreen mode Exit fullscreen mode
pytest -q test_config_catalog.py
Enter fullscreen mode Exit fullscreen mode

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)