DEV Community

Avery Lin
Avery Lin

Posted on

Harvest Pydantic Settings Into a Config Atlas; Sign Secrets, Defaults, and Restarts by Hand

Configuration reference pages collapse when generated prose and operational truth share one file without explicit ownership lanes. A settings class already enumerates keys, types, and defaults, but production meaning still lives in operator memory. The durable split is a harvested key atlas, model-drafted non-secret descriptions, and human signatures for secrets, default blasts, and restarts. Downstream pages should compile from that signed atlas rather than from unconstrained chat transcripts or README folklore.

This workflow treats documentation generation as a compile problem with two lanes, not as a single prompt that emits a finished reference. Mechanical harvest owns names, types, defaults, and source locations because those facts already exist in code. A model may draft grouping labels and non-secret descriptions after the harvest, never before it. A human must still classify secrets, record default-change blast radius, and state whether a process restart is required.

Why unsigned config docs fail under review

Reviewers do not fail config docs because the prose is clumsy; they fail them because operational cells were never owned. A paragraph can describe DATABASE_URL fluently while still leaking a connection string pattern that belongs in a secret manager. A default of DEBUG=true can look harmless in a table until someone ships it to a shared staging cluster. Restart requirements are worse, because a regenerated page will not mention that changing WORKER_COUNT needs a rolling bounce.

Generated documentation also ages in a specific way that status pages and READMEs hide. Keys get renamed in code while the prose file keeps the old identifier, and CI still reports a green docs job. Defaults change in a patch release, yet the published table keeps the previous value because no test compared harvest output to git. The fix is not a longer prompt. The fix is an atlas file that CI can diff, plus signature cells that refuse to publish when empty.

Lane contract for a configuration atlas

Define the lanes before any drafting pass, and keep the contract in the repository beside the harvest script. The compile lane may write only facts that a parser can prove from the settings class and its field metadata. The signature lane may be empty in git for a few hours, but it cannot be empty at docs publish time. Model output is allowed solely inside cells that the contract marks draft_ok.

Use these ownership rules as the working contract for every key in the atlas:

  1. Compile lane: name, python_type, default_repr, required, source_file, source_line, env_name.
  2. Draft-ok lane: summary, group, non_secret_example when secret_class is public.
  3. Signature lane: secret_class, default_blast, restart_contract, deprecation_date, signer.
  4. Forbidden lane: live secret values, production hostnames, customer identifiers, and unsigned default-change advice.

If a cell sits in the signature lane, a model must not fill it, even when the wording looks like ordinary documentation. If a cell sits in the compile lane, a human should not retype it, because retyping recreates drift. The rest of this article implements that split with a harvest script, a fixture atlas, and a pytest gate.

Step 1: Harvest fields from the settings class

Start from one Pydantic settings module so the parser has a closed world. The sample below is labeled as a proposal for a small worker process, not as production telemetry from a named company. Keep secret-looking defaults out of the class body; missing required secrets should raise at boot rather than print into docs.

# settings.py — proposal / example module, not a production dump
from pydantic import Field, SecretStr
from pydantic_settings import BaseSettings, SettingsConfigDict


class WorkerSettings(BaseSettings):
    model_config = SettingsConfigDict(env_prefix="WORKER_", extra="forbid")

    log_level: str = Field(default="INFO", description="stdlib logging level name")
    worker_count: int = Field(default=2, ge=1, le=64)
    request_timeout_ms: int = Field(default=8000, ge=100)
    debug: bool = Field(default=False)
    database_url: SecretStr
    redis_url: SecretStr
    feature_compact_payloads: bool = Field(default=True)
Enter fullscreen mode Exit fullscreen mode

Harvest must read field names, annotations, defaults, and source lines without importing application boot code that talks to the network. inspect.getsource plus Pydantic model_fields is enough for this shape. Do not ask a model to list the keys, because that list is already authoritative in the class.

# harvest_settings.py
from __future__ import annotations

import inspect
import json
from pathlib import Path
from typing import Any

from pydantic import SecretStr
from pydantic_settings import BaseSettings

SIGNATURE_CELLS = (
    "secret_class",
    "default_blast",
    "restart_contract",
    "deprecation_date",
    "signer",
)


def harvest(settings_cls: type[BaseSettings]) -> dict[str, Any]:
    source_file = inspect.getsourcefile(settings_cls) or "unknown"
    lines, start = inspect.getsourcelines(settings_cls)
    items = []
    for name, field in settings_cls.model_fields.items():
        annotation = field.annotation
        is_secret = annotation is SecretStr or getattr(annotation, "__name__", "") == "SecretStr"
        default = field.default
        required = field.is_required()
        default_repr = "<required>" if required else repr(default)
        items.append(
            {
                "name": name,
                "env_name": f"WORKER_{name.upper()}",
                "python_type": getattr(annotation, "__name__", str(annotation)),
                "required": required,
                "default_repr": default_repr if not is_secret else "<redacted-default>",
                "source_file": source_file,
                "source_line": start,
                "secret_hint_from_type": is_secret,
                "summary": None,
                "group": None,
                "non_secret_example": None,
                "secret_class": "secret" if is_secret else None,
                "default_blast": None,
                "restart_contract": None,
                "deprecation_date": None,
                "signer": None,
            }
        )
    return {"settings_class": settings_cls.__name__, "keys": items}


def write_atlas(payload: dict[str, Any], path: Path) -> None:
    path.write_text(json.dumps(payload, indent=2) + "\n", encoding="utf-8")
Enter fullscreen mode Exit fullscreen mode

Type metadata can pre-label SecretStr fields as secret, which is a compile-lane hint rather than a finished classification. Humans still confirm secret_class, because some str fields are credentials without a secret type wrapper. Never copy field values from the environment into the atlas during harvest.

Step 2: Emit a machine-readable atlas with empty signature cells

Write the harvest to config_atlas.json and keep it in version control as a reviewed artifact, not as build debris. Empty signature cells are intentional on first run; they are the backlog. A checked-in atlas also gives code review a stable diff when someone adds a settings field.

{
  "settings_class": "WorkerSettings",
  "keys": [
    {
      "name": "worker_count",
      "env_name": "WORKER_WORKER_COUNT",
      "python_type": "int",
      "required": false,
      "default_repr": "2",
      "source_file": "settings.py",
      "source_line": 8,
      "secret_hint_from_type": false,
      "summary": null,
      "group": null,
      "non_secret_example": null,
      "secret_class": null,
      "default_blast": null,
      "restart_contract": null,
      "deprecation_date": null,
      "signer": null
    }
  ]
}
Enter fullscreen mode Exit fullscreen mode

Merge rules matter as much as the harvest itself, because a naive overwrite would erase signed cells. Load the previous atlas, replace compile-lane fields from the parser, and preserve signature-lane fields when the key name still exists. Deleted keys must not disappear silently; move them to a removed_keys list so deprecation text cannot vanish between releases.

COMPILE_CELLS = {
    "name",
    "env_name",
    "python_type",
    "required",
    "default_repr",
    "source_file",
    "source_line",
    "secret_hint_from_type",
}


def merge_atlas(old: dict, new: dict) -> dict:
    old_by_name = {row["name"]: row for row in old.get("keys", [])}
    merged = []
    seen = set()
    for row in new["keys"]:
        seen.add(row["name"])
        prior = old_by_name.get(row["name"], {})
        combined = dict(row)
        for cell in SIGNATURE_CELLS:
            combined[cell] = prior.get(cell, row.get(cell))
        for draft_cell in ("summary", "group", "non_secret_example"):
            combined[draft_cell] = prior.get(draft_cell, row.get(draft_cell))
        merged.append(combined)
    removed = [old_by_name[name] for name in old_by_name if name not in seen]
    return {
        "settings_class": new["settings_class"],
        "keys": merged,
        "removed_keys": removed,
    }
Enter fullscreen mode Exit fullscreen mode

Step 3: Allow model drafts only on unsigned description cells

After merge, a drafting pass may fill summary, group, and non_secret_example for keys whose secret_class is already public. It must skip every signature cell and every key that is still unclassified. Feed the model the atlas JSON, not the raw settings module, so the prompt cannot invent extra keys. Require the tool to rewrite only those three draft-ok fields and to echo all other fields unchanged.

Disclosure: This article was prepared as part of MonkeyCode's product outreach. MonkeyCode's free model access and free server option can host that constrained drafting pass when you do not want a local GPU involved. The product is relevant here only as a writer for summary and group cells; it does not classify secrets or sign restart contracts. If you try the pass, keep the model output on a branch until the pytest gate in step 5 is green.

A minimal prompt envelope looks like the following proposal, which you should store as a checked-in template rather than as chat history. Notice the envelope names the allowed fields and the stop conditions in plain JSON, not in marketing language.

# proposal prompt envelope — not an executed vendor transcript
You receive config_atlas.json.
Rewrite only keys where secret_class == "public".
You may edit: summary, group, non_secret_example.
You must copy every other field verbatim.
non_secret_example must not contain passwords, tokens, hostnames, or user ids.
If secret_class is null or secret, leave draft fields unchanged.
Return the full JSON document.
Enter fullscreen mode Exit fullscreen mode

Do not let the drafting pass create Markdown yet. Markdown is a compile target, and compiling too early hides unsigned cells inside flowing sentences. Keep the atlas as JSON until signatures exist, then render tables in a later deterministic step.

Step 4: Sign secret class, default blasts, and restart contracts

Signature cells need a named owner and a closed vocabulary, or reviewers cannot tell a real sign-off from leftover draft text. secret_class should be one of public, secret, or internal. restart_contract should be one of hot_reload, process_restart, or cluster_bounce. default_blast is the only free-text signature cell, and it must describe who feels a default change, not how the parser works.

Worked signatures for the sample module look like this after human edit. These rows are proposed documentation, not measured incident data.

{
  "name": "debug",
  "secret_class": "internal",
  "default_blast": "Default true would emit request bodies to shared logs.",
  "restart_contract": "process_restart",
  "deprecation_date": null,
  "signer": "docs-oncall"
}
Enter fullscreen mode Exit fullscreen mode
{
  "name": "database_url",
  "secret_class": "secret",
  "default_blast": "No default; missing value must fail boot in every environment.",
  "restart_contract": "cluster_bounce",
  "deprecation_date": null,
  "signer": "docs-oncall"
}
Enter fullscreen mode Exit fullscreen mode

Refuse examples on secret keys even when a model offers a “redacted” sample that still shows a vendor URL shape. non_secret_example for log_level may be "INFO". non_secret_example for database_url must remain null. If a signer is tempted to paste a rotated credential as a teaching aid, the atlas format is already the wrong place; put rotation steps in a private runbook that never enters the docs job.

Step 5: Gate the atlas in CI before docs publish

The publish job should fail closed. Harvest again in CI, merge against the committed atlas, and assert that compile-lane fields match the parser. Then assert that every live key has secret_class, default_blast, restart_contract, and signer. Finally assert that secret keys still have null draft examples.

# test_config_atlas.py
import json
from pathlib import Path

from harvest_settings import COMPILE_CELLS, harvest, merge_atlas
from settings import WorkerSettings

ATLAS = Path("config_atlas.json")


def test_compile_lane_matches_parser():
    committed = json.loads(ATLAS.read_text(encoding="utf-8"))
    fresh = harvest(WorkerSettings)
    merged = merge_atlas(committed, fresh)
    left = {row["name"]: {k: row[k] for k in COMPILE_CELLS} for row in merged["keys"]}
    right = {row["name"]: {k: row[k] for k in COMPILE_CELLS} for row in fresh["keys"]}
    assert left == right


def test_signature_cells_are_complete():
    committed = json.loads(ATLAS.read_text(encoding="utf-8"))
    missing = []
    for row in committed["keys"]:
        for cell in ("secret_class", "default_blast", "restart_contract", "signer"):
            if not row.get(cell):
                missing.append(f"{row['name']}.{cell}")
    assert missing == []


def test_secrets_have_no_examples():
    committed = json.loads(ATLAS.read_text(encoding="utf-8"))
    leaked = [
        row["name"]
        for row in committed["keys"]
        if row.get("secret_class") == "secret" and row.get("non_secret_example")
    ]
    assert leaked == []
Enter fullscreen mode Exit fullscreen mode

Render Markdown only after those tests pass. A twenty-line renderer that prints a table from JSON is enough, and it keeps typography out of the model. If the renderer needs a sentence of intro prose, write that sentence in the docs template repository and keep it under human review.

# render_config_docs.py — deterministic compile, not a drafting step
from __future__ import annotations

import json
from pathlib import Path


def render(atlas_path: Path) -> str:
    data = json.loads(atlas_path.read_text(encoding="utf-8"))
    lines = ["# Worker configuration", "", "| Key | Env | Default | Secret class | Restart | Signer |", "|---|---|---|---|---|---|"]
    for row in data["keys"]:
        lines.append(
            f"| `{row['name']}` | `{row['env_name']}` | `{row['default_repr']}` | "
            f"{row['secret_class']} | {row['restart_contract']} | {row['signer']} |"
        )
    return "\n".join(lines) + "\n"
Enter fullscreen mode Exit fullscreen mode

Decision table: model draft versus human signature

The table below is the artifact reviewers can paste into a docs RFC. It is a policy object, not a benchmark, and it does not claim accuracy rates for any model.

Cell Source of truth Model may draft Publish if empty
name, env_name, python_type Parser on settings class No No
default_repr Parser, redacted for secrets No No
summary, group Draft-ok after secret_class=public Yes Yes, with a stub
non_secret_example Draft-ok for public keys only Yes Yes
secret_class Human, type hint is only a hint No No
default_blast Human incident and rollout knowledge No No
restart_contract Human runtime knowledge No No
deprecation_date Human release plan No Yes if still active
signer Human identity No No

Read the table as a deny-by-default list. Anything not named as draft-ok stays in the signature lane even when a model could produce plausible text. Plausible text is the failure mode this pipeline exists to stop, because plausible config advice is how a wrong default reaches staging.

Limitations and who should skip this workflow

This approach assumes a single settings class, or a small set of classes with stable field names, and it assumes CI can import that module without side effects. Dynamic configuration loaded from a remote store will not harvest cleanly, because the parser cannot see keys that exist only at runtime. Polyglot services that split the same env prefix across two languages need a second harvester; this script will not invent those keys.

The atlas also does not replace a threat model. Labeling a field secret does not encrypt it, rotate it, or prove that logs already omit it. Default-blast notes are only as good as the signer’s knowledge of downstream consumers, including batch jobs that nobody listed. If your team cannot name a signer, the gate will block publish, which is correct, and also a reason not to adopt the pipeline during a staffing gap.

Skip this workflow when the document is a one-off gist, when settings are not the source of truth, or when legal review requires every sentence to be human-authored. Skip it for customer-facing security advisories, because those pages should not contain harvested defaults at all. Skip it if the goal is a marketing comparison of coding assistants, because the useful output is the atlas and the tests, not a product ranking.

Start with one settings class, merge against an empty atlas, and refuse to publish until every signature cell has a named owner. The compile lane will stay cheap after that, and the expensive work remains where it already belonged: secret class, default blasts, and restart contracts written by a human.

Top comments (0)