Treat settings documentation as two artifacts rather than one generated page, because compilers list keys while humans still own production meaning. A static pass over getenv calls, Settings fields, and .env.example lines can emit a complete atlas of names. That atlas is not a contract until someone signs secret class, production default, restart need, and blast radius. Models may draft grouping and comment-based blurbs from the atlas, but they must not fill those four signed cells.
Why generated env docs go stale
Chat-written configuration guides drift because they start from memory of names instead of a parse of current readers. Repositories grow feature flags, timeout knobs, and vendor URLs across services without a single index. Reviewers then approve prose that still documents removed keys or omits new ones added last week. The failure is not tone; it is missing compile input and missing ownership of operational facts.
Public names are usually cheap to extract, while operational meaning is expensive and often absent from source. A comment that says "SMTP host" does not tell on-call whether a change requires a rolling restart. A default in a constructor does not tell whether production overrides it, or whether the value is a credential. Treat those gaps as documentation defects with owners, not as prompts for a model to guess.
Compile lane versus signature lane
Keep two files in version control and refuse to merge them during generation. The compile lane is a JSON atlas of keys the tree actually reads, plus source locations and nearby comments. The signature lane is a YAML ownership sheet whose cells a human must fill, including secret class and blast radius. A docs job may render both into Markdown, but it must fail if a compiled key has no signed row, or if a signed row names a key the atlas no longer contains.
The table below is the rule set for this workflow, not a product comparison. Apply it before any model sees the atlas, so draft prose cannot quietly become production truth.
| Cell | Source of truth | Model may draft? | Merge rule |
|---|---|---|---|
| Key name, file, line, call shape | AST / .env.example
|
No | Compiler overwrites on each run |
| Comment excerpt, suggested group | Source comments | Yes, as unlabeled draft | Discard unless a human keeps it |
Secret class (public, internal, secret) |
Security owner | No | Block docs publish if empty |
| Production default / “unset means” | Runtime owner | No | Never copy constructor defaults blindly |
| Restart required | Operator | No | Signature file only |
| Blast radius | Operator | No | Signature file only |
| Last verified date, signer | Human | No | CI checks ISO date freshness |
Artifact: extract a settings-key atlas
The script below is a proposed local compiler. It walks Python files, records os.getenv, os.environ.get, and os.environ[...] with literal keys, and also reads .env.example. It does not open .env, does not print values, and does not invent secret classifications. Dynamic keys such as os.environ[prefix + name] are emitted as unresolved rows so humans can decide whether to document them by hand.
#!/usr/bin/env python3
"""Compile a settings-key atlas. Proposed example; not a production scanner."""
from __future__ import annotations
import argparse
import ast
import json
from pathlib import Path
from typing import Any
SKIP_DIRS = {".git", ".venv", "venv", "node_modules", "__pycache__"}
class EnvVisitor(ast.NodeVisitor):
def __init__(self, rel: str) -> None:
self.rel = rel
self.rows: list[dict[str, Any]] = []
def _comment(self, lineno: int, source: str) -> str:
lines = source.splitlines()
idx = lineno - 2
if 0 <= idx < len(lines):
prev = lines[idx].strip()
if prev.startswith("#"):
return prev[1:].strip()[:160]
return ""
def _add(self, key: str | None, lineno: int, shape: str, source: str) -> None:
self.rows.append(
{
"key": key,
"file": self.rel,
"line": lineno,
"shape": shape,
"comment": self._comment(lineno, source),
"resolved": key is not None,
}
)
def visit_Call(self, node: ast.Call) -> None:
func = node.func
name = ""
if isinstance(func, ast.Attribute) and isinstance(func.value, ast.Attribute):
if (
isinstance(func.value.value, ast.Name)
and func.value.value.id == "os"
and func.value.attr == "environ"
and func.attr == "get"
):
name = "os.environ.get"
elif isinstance(func, ast.Attribute) and isinstance(func.value, ast.Name):
if func.value.id == "os" and func.attr == "getenv":
name = "os.getenv"
if name and node.args:
arg0 = node.args[0]
key = arg0.value if isinstance(arg0, ast.Constant) and isinstance(arg0.value, str) else None
self._add(key, node.lineno, name, self.source)
self.generic_visit(node)
def visit_Subscript(self, node: ast.Subscript) -> None:
val = node.value
if (
isinstance(val, ast.Attribute)
and isinstance(val.value, ast.Name)
and val.value.id == "os"
and val.attr == "environ"
):
sl = node.slice
key = sl.value if isinstance(sl, ast.Constant) and isinstance(sl.value, str) else None
self._add(key, node.lineno, "os.environ[]", self.source)
self.generic_visit(node)
def parse_python(path: Path, root: Path) -> list[dict[str, Any]]:
source = path.read_text(encoding="utf-8")
tree = ast.parse(source)
visitor = EnvVisitor(str(path.relative_to(root)))
visitor.source = source
visitor.visit(tree)
return visitor.rows
def parse_env_example(path: Path, root: Path) -> list[dict[str, Any]]:
rows = []
for i, line in enumerate(path.read_text(encoding="utf-8").splitlines(), start=1):
stripped = line.strip()
if not stripped or stripped.startswith("#") or "=" not in stripped:
continue
key = stripped.split("=", 1)[0].strip()
if key:
rows.append(
{
"key": key,
"file": str(path.relative_to(root)),
"line": i,
"shape": "env.example",
"comment": "",
"resolved": True,
}
)
return rows
def compile_atlas(root: Path) -> dict[str, Any]:
rows: list[dict[str, Any]] = []
for path in root.rglob("*"):
if any(part in SKIP_DIRS for part in path.parts):
continue
if path.suffix == ".py":
rows.extend(parse_python(path, root))
elif path.name == ".env.example":
rows.extend(parse_env_example(path, root))
resolved = [r for r in rows if r["resolved"]]
keys = sorted({r["key"] for r in resolved if r["key"]})
return {
"root": str(root),
"key_count": len(keys),
"unresolved_count": sum(1 for r in rows if not r["resolved"]),
"keys": keys,
"rows": rows,
}
def main() -> None:
parser = argparse.ArgumentParser(description="Compile a settings-key atlas")
parser.add_argument("root", type=Path)
parser.add_argument("--out", type=Path, default=Path("settings_atlas.json"))
args = parser.parse_args()
atlas = compile_atlas(args.root.resolve())
args.out.write_text(json.dumps(atlas, indent=2) + "\n", encoding="utf-8")
print(f"wrote {args.out} keys={atlas['key_count']} unresolved={atlas['unresolved_count']}")
if __name__ == "__main__":
main()
Run it against a fixture tree that contains names only, never against a directory that holds live secret files.
python extract_settings_atlas.py ./fixtures/settings_app --out settings_atlas.json
python -c "import json; d=json.load(open('settings_atlas.json')); print(d['key_count'], d['unresolved_count'])"
A matching ownership sheet starts empty except for key names copied from the atlas. Humans fill the remaining columns in review, not in a chat transcript.
# settings_ownership.yaml — signature lane; do not generate values
keys:
SMTP_HOST:
secret_class: public
production_default: smtp.internal.example
unset_means: application refuses to start mailer
restart_required: true
blast_radius: outbound mail for all tenants on this process
signer: sre-mail
last_verified: 2026-09-18
PAYMENTS_WEBHOOK_SECRET:
secret_class: secret
production_default: null
unset_means: webhook handler disabled
restart_required: true
blast_radius: payment confirmation path only
signer: payments-oncall
last_verified: 2026-09-18
Numbered workflow
-
Freeze the compile inputs. Point the extractor at application packages and
.env.exampleonly. Exclude.env, sealed secrets, and CI credential maps so values never enter the atlas JSON. -
Emit
settings_atlas.jsonin CI. Fail the job whenunresolved_countrises without a matching note in the ownership sheet, because dynamic keys are a documentation hole. -
Diff keys against
settings_ownership.yaml. Added keys require empty signed rows; removed keys require deletion of signed rows. Do not keep orphan signatures that document dead configuration. - Draft only unlabeled prose from the atlas. Grouping paragraphs and comment paraphrases may come from a model. Secret class, production default, restart, and blast radius stay human-authored.
-
Render docs from both files. A small template can print a table of keys with signed cells. The renderer must show
UNSIGNEDrather than invent a default when a cell is empty. - Age-check signatures. Reject last_verified dates older than a window your team chooses. Ninety days is a starting policy for public keys; secret rows usually need a shorter window.
If a hosted editor is useful for the draft table, keep the same split. Disclosure: This article was prepared as part of MonkeyCode's product outreach. MonkeyCode offers free model access and a free server option, which can host the extractor and the unlabeled draft step without putting those tools in the title of the document. Upload settings_atlas.json only, never .env, and paste signed YAML back into git by hand so production defaults are not model output.
Tests that keep the atlas honest
The test is a proposed fixture check, not a claim about a production repository. It asserts that literal keys are found, that dynamic keys are unresolved, and that .env.example contributes names without values.
# test_settings_atlas.py
from pathlib import Path
from extract_settings_atlas import compile_atlas
def test_literal_and_unresolved_keys(tmp_path: Path) -> None:
app = tmp_path / "app"
app.mkdir()
(app / "conf.py").write_text(
"import os\n"
"# outbound mail host\n"
"host = os.getenv('SMTP_HOST')\n"
"secret = os.environ.get('PAYMENTS_WEBHOOK_SECRET')\n"
"prefix = 'FLAG_'\n"
"dynamic = os.environ[prefix + 'BETA']\n",
encoding="utf-8",
)
(tmp_path / ".env.example").write_text(
"SMTP_HOST=\nPAYMENTS_WEBHOOK_SECRET=\n", encoding="utf-8"
)
atlas = compile_atlas(tmp_path)
assert set(atlas["keys"]) == {"SMTP_HOST", "PAYMENTS_WEBHOOK_SECRET"}
assert atlas["unresolved_count"] == 1
shapes = {row["shape"] for row in atlas["rows"] if row.get("key") == "SMTP_HOST"}
assert "os.getenv" in shapes and "env.example" in shapes
pytest -q test_settings_atlas.py
Add a second CI assertion that every atlas key exists in settings_ownership.yaml and that secret_class is one of public, internal, or secret. That check is documentation quality, not an application unit test, and it should run on the docs pipeline.
What the model should never receive
Do not send constructor defaults that happen to be credentials, sample JWT strings, or real hostnames that locate a private network. Do not ask a model to infer PCI or PII class from a key name, because USER_TOKEN can be a UI feature flag in one service and a session secret in another. Do not let draft grouping rename keys; the atlas name is the identifier on-call will grep. When comment text contradicts the signed sheet, the sheet wins and the comment becomes a code-cleanup ticket.
Limitations
This compiler misses keys built at runtime, keys read from YAML/JSON config loaders, and keys referenced only in shell charts or Helm templates. It also misses non-Python services unless you add a second extractor with the same JSON schema. Comments are often stale, so draft blurbs can be fluent and wrong at the same time. The ownership sheet can rot if last_verified is not enforced, which returns the team to narrative docs with no compile input.
The workflow does not classify vulnerability impact, does not rotate secrets, and does not prove that production actually uses the signed default. Those are operational controls outside the documentation job. If your configuration surface is three keys, a spreadsheet already solves the problem and the atlas adds ceremony without reducing risk.
Who should not use this approach
Skip it when the repository is not allowed to list configuration names on any hosted model, including free-model endpoints, because names alone can reveal vendor layout. Skip it when production defaults are themselves classified and cannot live in git, even in a signed YAML file. Skip it when the team wants a single chat transcript to stand in for an on-call contract. In those cases, keep an internal CMDB as the signature lane and use the extractor only as a drift alarm.
Teams that benefit are those already generating API or runbook pages from reviewed facts, and those that keep seeing env-guide pull requests that invent keys. The original artifact is the atlas-plus-sheet pair, not the draft paragraphs. If you try the free-model draft path on a free server option, treat that output as unlabeled comments beside the table, then require the same signature review you would demand for a hand-written runbook.
Top comments (0)