Configuration reference pages go stale the moment a model invents a default, a secret class, or a restart requirement the tree never encoded. A durable alternative compiles every os.getenv and os.environ call site into a settings catalog before any prose model runs. That catalog may carry names, files, line numbers, and nearby comments, because those facts are recoverable from source without interpretation. Secret class, default safety, and process restart cost remain human-signed cells, since a wrong value changes how production is operated.
This article treats documentation generation as a two-cell workflow rather than as one unbounded chat transcript. Compile cells stay mechanical and testable, while sign cells capture policy that source code cannot prove. Mixing those cells is how internal wikis publish a password default that never existed in the tree. The same mix also drops restart cost from the page, which on-call later rediscovers during a configuration change.
What the compiler may emit
An AST walk can list environment key names without executing the process or importing application modules. It can record the literal default passed to os.getenv, the enclosing file, and a short comment on the previous line. It can also flag dynamic keys built from f-strings or concatenation, which the extractor must not guess. Those outputs are evidence for documentation work, and they are not finished operator guidance by themselves.
Dynamic construction is the first failure mode that most generated configuration chapters quietly hide from reviewers. If the key is built as prefix plus TOKEN, the catalog should mark the row unresolved instead of inventing a name. Unresolved rows still need a human owner, because missing keys are how secrets leak into example blocks.
What a human must own
Three cells should never be auto-filled from a general-purpose model, even when nearby comments look confident and complete. Secret class decides whether the value may appear in examples, continuous integration logs, or process crash dumps. Default safety decides whether an empty value or a documented default is lawful inside a production process. Restart cost decides whether a change is a hot reload, a rolling restart, or a dated maintenance window.
Customer-facing wording is a fourth signed cell whenever the catalog feeds public documentation rather than an internal wiki. Internal comments often name vendors, incident tickets, or unreleased product lines that should never be copied outward. A model that rewrites comments into README prose will copy those details unless a human strips them first.
Ownership matrix
The table below is the working artifact that later tests should enforce in documentation continuous integration. Rows are catalog fields, and each column states who is allowed to write that field during the pipeline. The compiler column may write during extraction, the model column may draft from comments, and the human column is required before render.
| Field | Compiler | Model draft | Human sign | Render blocked if unsigned |
|---|---|---|---|---|
key |
yes, literal only | no | confirm unresolved keys | yes if unresolved |
path / lineno
|
yes | no | no | no |
literal_default |
yes if constant | no | rewrite if misleading | yes when empty and required |
nearby_comment |
yes, raw | no | no | no |
draft_description |
no | yes, from comment only | edit | no |
secret_class |
no | no | public / restricted / secret | yes |
default_safety |
no | no | safe / unsafe-in-prod / unknown | yes |
restart_cost |
no | no | none / rolling / window | yes |
example_value |
no | no | synthetic only | yes when class is secret |
This matrix is also a review checklist that keeps security review off purely editorial pull requests. Changes that touch only draft_description can skip secret classification review if the gate still passes. Changes that alter secret_class, default_safety, or restart_cost cannot skip that review, because they change operations.
1. Inventory call sites with a frozen extractor
Keep the extractor boring so it parses files instead of importing the application package at documentation build time. Importing settings modules often connects to real backends, which is an unacceptable side effect for a docs compiler. Limit the first version to os.getenv, os.environ.get, and os.environ subscript access when the key is a constant string.
# extract_settings_catalog.py
from __future__ import annotations
import ast
import json
import sys
from pathlib import Path
class SettingsVisitor(ast.NodeVisitor):
def __init__(self, path: str) -> None:
self.path = path
self.rows: list[dict] = []
self._comments: dict[int, str] = {}
def load_comments(self, source: str) -> None:
for lineno, line in enumerate(source.splitlines(), start=1):
stripped = line.strip()
if stripped.startswith("#"):
self._comments[lineno] = stripped[1:].strip()
def _const_str(self, node: ast.AST) -> str | None:
if isinstance(node, ast.Constant) and isinstance(node.value, str):
return node.value
return None
def _row(self, key: str | None, form: str, default: str | None, lineno: int) -> dict:
comment = self._comments.get(lineno - 1, "")
return {
"key": key or "<unresolved>",
"resolved": bool(key),
"form": form,
"literal_default": default,
"path": self.path,
"lineno": lineno,
"nearby_comment": comment,
"secret_class": None,
"default_safety": None,
"restart_cost": None,
"draft_description": None,
"example_value": None,
}
def visit_Call(self, node: ast.Call) -> None:
func = node.func
if isinstance(func, ast.Attribute) and func.attr == "getenv" and node.args:
key = self._const_str(node.args[0])
default = self._const_str(node.args[1]) if len(node.args) > 1 else None
self.rows.append(self._row(key, "os.getenv", default, node.lineno))
elif (
isinstance(func, ast.Attribute)
and func.attr == "get"
and isinstance(func.value, ast.Attribute)
and func.value.attr == "environ"
and node.args
):
key = self._const_str(node.args[0])
default = self._const_str(node.args[1]) if len(node.args) > 1 else None
self.rows.append(self._row(key, "os.environ.get", default, node.lineno))
self.generic_visit(node)
def visit_Subscript(self, node: ast.Subscript) -> None:
value = node.value
if isinstance(value, ast.Attribute) and value.attr == "environ":
key = self._const_str(node.slice)
self.rows.append(self._row(key, "os.environ[]", None, node.lineno))
self.generic_visit(node)
def extract(root: Path) -> list[dict]:
rows: list[dict] = []
for path in root.rglob("*.py"):
if any(part.startswith(".") for part in path.parts):
continue
source = path.read_text(encoding="utf-8")
tree = ast.parse(source)
visitor = SettingsVisitor(str(path))
visitor.load_comments(source)
visitor.visit(tree)
rows.extend(visitor.rows)
rows.sort(key=lambda r: (r["key"], r["path"], r["lineno"]))
return rows
if __name__ == "__main__":
root = Path(sys.argv[1] if len(sys.argv) > 1 else ".")
json.dump(extract(root), sys.stdout, indent=2)
sys.stdout.write("\n")
Run the extractor against the application checkout, and do not point it at a virtualenv or site-packages tree. Third-party packages contain their own getenv calls, and those keys do not belong in your operator catalog. The command below writes compiled JSON that continuous integration can regenerate on every documentation pipeline run.
python extract_settings_catalog.py ./src > catalog_compiled.json
2. Merge compiled rows into a signed YAML file
Compiled JSON should be regenerated in continuous integration and then treated as a disposable compiler artifact. Signed fields live in a second YAML file that humans edit, keyed by environment variable name plus source path. A small merge step copies compiler facts forward and preserves signatures when the same key still exists in source. Deleted keys disappear from the merged file, which prevents operators from copying examples for removed settings.
# merge_settings_catalog.py
from __future__ import annotations
import json
import sys
from pathlib import Path
import yaml # PyYAML in the docs toolchain only
SIGNED_FIELDS = (
"secret_class",
"default_safety",
"restart_cost",
"draft_description",
"example_value",
)
def row_id(row: dict) -> str:
return f"{row['key']}::{row['path']}"
def merge(compiled: list[dict], signed: dict) -> dict:
out = {}
for row in compiled:
ident = row_id(row)
prev = signed.get(ident, {})
merged = dict(row)
for field in SIGNED_FIELDS:
merged[field] = prev.get(field)
out[ident] = merged
return out
if __name__ == "__main__":
compiled = json.loads(Path(sys.argv[1]).read_text())
signed_path = Path(sys.argv[2])
previous = yaml.safe_load(signed_path.read_text()) if signed_path.exists() else {}
merged = merge(compiled, previous or {})
signed_path.write_text(yaml.safe_dump(merged, sort_keys=True))
The merge must not invent signed fields when a new key appears in compiled JSON for the first time. New keys enter the YAML with null signatures, and the gate described later will fail until a human fills them. That failure is the point of the pipeline, because unsigned configuration pages are worse than a red build.
python merge_settings_catalog.py catalog_compiled.json catalog_signed.yaml
3. Restrict any model to draft cells
Draft cells are the only place a general-purpose model should write during this configuration documentation workflow. Even then the prompt must receive the compiled catalog rather than the raw repository tree or runtime values. Disclosure: This article was prepared as part of MonkeyCode's product outreach. MonkeyCode's free model access and free server option can run that draft pass on extracted comments only.
That placement keeps secret values and unsigned defaults out of the prompt the model actually sees. The signer still reviews every row before the catalog is allowed to render into Sphinx, MkDocs, or an internal portal. Label the prompt as a proposal generator, not as a source of record for operator-facing configuration meaning.
# proposal only — do not write secret_class, default_safety, restart_cost, or example_value
For each JSON row, if nearby_comment is non-empty, draft one sentence of operator-facing description.
If nearby_comment is empty, set draft_description to null.
Never invent a default, a sample secret, a hostname, or a restart requirement.
Feed only key, form, literal_default, and nearby_comment, because those fields are already compiled from source. Instruct the model to leave draft_description empty when the comment is missing, since silence beats a hallucinated purpose. Store model output under draft_description, and never allow the model to overwrite YAML that already contains a human signature.
4. Gate render on unsigned rows
Documentation continuous integration should fail closed when required signatures are missing from any resolved catalog row. A missing secret_class value is a broken build, not a TODO comment left inside generated HTML. The checker below encodes the ownership matrix so a helpful model cannot quietly fill forbidden cells.
# check_settings_catalog.py
from __future__ import annotations
import sys
from pathlib import Path
import yaml
ALLOWED_SECRET = {"public", "restricted", "secret"}
ALLOWED_SAFETY = {"safe", "unsafe-in-prod", "unknown"}
ALLOWED_RESTART = {"none", "rolling", "window"}
def check(path: Path) -> list[str]:
data = yaml.safe_load(path.read_text()) or {}
errors: list[str] = []
for ident, row in data.items():
if not row.get("resolved"):
errors.append(f"{ident}: unresolved key requires a human note")
continue
if row.get("secret_class") not in ALLOWED_SECRET:
errors.append(f"{ident}: secret_class unsigned")
if row.get("default_safety") not in ALLOWED_SAFETY:
errors.append(f"{ident}: default_safety unsigned")
if row.get("restart_cost") not in ALLOWED_RESTART:
errors.append(f"{ident}: restart_cost unsigned")
if row.get("secret_class") == "secret" and row.get("example_value"):
errors.append(f"{ident}: secret rows may not ship example_value")
if row.get("secret_class") == "secret" and row.get("literal_default"):
errors.append(f"{ident}: secret rows still expose a literal default in source")
return errors
if __name__ == "__main__":
problems = check(Path(sys.argv[1]))
if problems:
sys.stderr.write("\n".join(problems) + "\n")
raise SystemExit(1)
The last rule is diagnostic rather than a documentation-only concern for secret keys with literal defaults. A secret that still carries a literal default in source is a code defect the catalog should not hide. Leaving that defect in the generated page teaches operators that the example default is an approved value.
python check_settings_catalog.py catalog_signed.yaml
5. Render only after the gate
A renderer can emit Markdown without asking a model to write the configuration chapter as free-form prose. Public keys may include signed examples, while restricted keys may include names and restart cost but not values. Secret keys may include the name, the owning file, and a pointer to the secret manager, and nothing else.
# render_settings_md.py (proposal: adapt paths to your docs tree)
from pathlib import Path
import yaml
def render(catalog_path: Path) -> str:
data = yaml.safe_load(catalog_path.read_text()) or {}
lines = ["# Configuration reference", ""]
for _ident, row in sorted(data.items()):
lines.append(f"## `{row['key']}`")
lines.append("")
desc = row.get("draft_description") or row.get("nearby_comment") or "No description signed."
lines.append(desc)
lines.append("")
lines.append(f"- Source: `{row['path']}:{row['lineno']}`")
lines.append(f"- Restart cost: `{row['restart_cost']}`")
if row["secret_class"] == "public" and row.get("example_value"):
lines.append(f"- Example: `{row['example_value']}`")
elif row["secret_class"] == "secret":
lines.append("- Value: stored in the secret manager; not documented here.")
lines.append("")
return "\n".join(lines)
This renderer is deliberately dull so it cannot reintroduce model drift between signed YAML and published pages. Dull renderers also make review cheaper, because the diff of the Markdown should match the YAML diff closely. If the rendered page contains a sentence that is not in the catalog, the renderer has grown a hidden model.
Test plan for the catalog
Treat the extractor like any other compiler, and lock its ownership rules with fixtures rather than with manual wiki review. The cases below are labeled as a proposal until they run in your tree, but they are specific enough to copy. Each case maps to one row in the ownership matrix so a later model change cannot refill forbidden fields.
- Fixture a module that calls
os.getenv("APP_PORT", "8080")and assert the catalog key, form, and literal default match the source. - Fixture a dynamic key such as
os.getenv(prefix + "_TOKEN")and assert thatresolvedis false in the compiled row. - Merge the catalog twice and assert a human
secret_classvalue survives a comment-only change in the Python file. - Put
secret_class: secretplus anexample_valuein YAML and assert the gate process exits with a nonzero status. - Render a secret row and assert the Markdown contains no example string and no literal default copied from source.
These tests lock the ownership matrix in a form that documentation CI can fail closed on. Without them, the next helpful model change will start filling example_value again during ordinary doc regeneration. Keep the fixtures next to the extractor so catalog rules do not live only in a style guide nobody runs.
Limitations
The extractor does not understand os.environ.update, dotenv loaders, or keys injected only by Kubernetes manifests. It also misses settings read from pydantic BaseSettings field names unless those fields call os.getenv in source. Teams with a single typed settings class should compile from that class instead of from scattered call sites. They should still keep the same signed cells for secret class, default safety, and restart cost after compilation.
Comment harvesting remains brittle because a previous-line comment may describe the whole block rather than one key. The catalog therefore stores raw comments and refuses to treat those strings as signed operator descriptions. Literal defaults that are names, such as a DEFAULT_HOST symbol, appear unresolved for the default and need a rewrite.
The pipeline does not classify secrets by entropy, by vault path, or by a regular expression over key names. Name heuristics such as contains TOKEN are useful lint, and they are not a substitute for a human signature. Those heuristics also fail for bucket credentials named APP_BUCKET and for public keys named SECRET_FEATURE_FLAG.
Who should not use this approach
Do not use this workflow to publish customer documentation directly from internal comments without a disclosure review. Internal comments are not a legal or editorial review, and they often contain vendor names and incident numbers. Do not use the catalog as a substitute for a secret scanner that looks at values, logs, and built artifacts. A catalog that lists STRIPE_SECRET_KEY is still a map of where secrets enter the process, not a control.
Skip the model draft pass entirely when the repository is small enough that signing descriptions takes less time than prompt review. The compile-and-gate half remains useful without any model, and it is the half that prevents invented defaults. Skip the whole method when configuration lives only in a remote control plane with no source call sites to compile.
Teams that already have a free model endpoint can point it at draft_description only, then keep signed YAML in ordinary code review. That split preserves the catalog as evidence, and it keeps policy language in the same review path as code. If the draft pass starts filling secret_class, stop the integration instead of widening the prompt to be more helpful.
Top comments (0)