DEV Community

Avery Lin
Avery Lin

Posted on

Treat Config Docs as Two Artifacts: Extracted Inventory and Signed Promises

Treat configuration documentation as two artifacts, because mixing inventory with promises is how unsigned defaults reach users. A model can list keys and call sites after an extractor walks the syntax tree of a typical codebase. A human still has to sign defaults, secret handling, deprecation windows, and every production-support claim. The rest of this article is a gate that refuses generated copy until those owned fields exist.

Why config pages fail as a single generated document

Most configuration pages rot for a mechanical reason rather than for weak writing or missing screenshots. Keys appear in code, then a chat session invents a default, a unit, and a support story. Reviewers often read the surrounding prose and miss the unsigned policy sitting inside a table cell. Users then treat that table cell as a support contract the maintainers never actually accepted.

Inventory is cheap to regenerate after every merge, and that regeneration is the part generation can help. Promises are expensive because they bind release managers, support staff, and security reviewers to a public statement. A useful workflow therefore lets a model draft structure and descriptions while blocking merge on empty human-owned fields. The ownership matrix below is the contract between those two jobs.

Ownership matrix: draftable inventory versus signed promises

Use the table as a review checklist rather than as a style guide for tone or brand voice. Extracted fields come from syntax and should be rebuilt in CI whenever the tree changes. Signed fields bind the project to users, so they stay UNSET until a named person replaces them. Generated prose that fills promise columns is a defect in this workflow, not a shortcut.

Field Source of truth Model may draft? Human must sign? Merge if UNSET?
Key name Source identifiers No; extract only Confirm spelling No
Call sites AST or equivalent inventory No; extract only Spot-check paths No if extractor is empty
Type hint Annotations and usage Yes, as a proposal Confirm Warning only
Short description Nearby comments Yes Edit for accuracy Allowed with a draft flag
Default value Runtime plus deploy overlays No Yes No
Secret / redaction Threat model No Yes No
Environment scope Operations policy Proposal only Yes No
Deprecation window Release policy No Yes No
Production support Support policy No Yes No
Example value Sanitized samples Yes, fake data only Confirm no live secrets No if the example looks real

The rule is simple enough to encode in CI, and that encoding is the point of the matrix. Anything recoverable from syntax is inventory and can be rebuilt without scheduling a meeting. Anything that would surprise a customer if wrong is a promise and needs a named signer. That split is what the extractor, the ownership file, and the gate implement in the next sections.

1. Extract inventory from source, never from chat memory

Start from the repository, not from a prompt that asks a model to remember your flags. The following Python example walks an abstract syntax tree for os.getenv and os.environ.get calls. Treat the script as a worked example you can extend to argparse, Click, or YAML loaders. It writes JSON inventory and never writes user-facing defaults, support windows, or secret labels.

#!/usr/bin/env python3
"""Extract configuration key inventory from Python sources. Worked example."""
from __future__ import annotations

import ast
import json
import sys
from pathlib import Path


def call_accessor(func: ast.AST) -> str | None:
    if isinstance(func, ast.Attribute) and isinstance(func.value, ast.Name):
        if func.value.id == "os" and func.attr == "getenv":
            return "os.getenv"
    if (
        isinstance(func, ast.Attribute)
        and func.attr == "get"
        and isinstance(func.value, ast.Attribute)
        and func.value.attr == "environ"
        and isinstance(func.value.value, ast.Name)
        and func.value.value.id == "os"
    ):
        return "os.environ.get"
    return None


def const_str(node: ast.AST | None) -> str | None:
    if isinstance(node, ast.Constant) and isinstance(node.value, str):
        return node.value
    return None


class EnvKeyVisitor(ast.NodeVisitor):
    def __init__(self, relpath: str) -> None:
        self.relpath = relpath
        self.rows: list[dict] = []

    def visit_Call(self, node: ast.Call) -> None:
        accessor = call_accessor(node.func)
        if accessor and node.args:
            key = const_str(node.args[0])
            source_default = const_str(node.args[1]) if len(node.args) > 1 else None
            if key:
                self.rows.append(
                    {
                        "key": key,
                        "accessor": accessor,
                        "literal_default_in_source": source_default,
                        "path": self.relpath,
                        "line": node.lineno,
                    }
                )
        self.generic_visit(node)


def extract(root: Path) -> list[dict]:
    rows: list[dict] = []
    skip = {".venv", "venv", "node_modules", ".git"}
    for path in root.rglob("*.py"):
        if any(part in skip for part in path.parts):
            continue
        rel = str(path.relative_to(root))
        try:
            tree = ast.parse(path.read_text(encoding="utf-8"), filename=rel)
        except (SyntaxError, UnicodeDecodeError):
            continue
        visitor = EnvKeyVisitor(rel)
        visitor.visit(tree)
        rows.extend(visitor.rows)
    rows.sort(key=lambda row: (row["key"], row["path"], row["line"]))
    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")
Enter fullscreen mode Exit fullscreen mode

Run the script against a checkout and store the JSON as a build artifact rather than as published documentation. A literal default found in source remains inventory, because deploy overlays can still replace it at runtime. Do not copy that literal into a user-facing Default column without a named human signer. Regenerate the inventory file in CI so missing keys fail the build instead of rotting on a wiki page.

mkdir -p build
python3 extract_config_keys.py . > build/config-inventory.json
Enter fullscreen mode Exit fullscreen mode

2. Keep a human-owned promise file beside the inventory

Create docs/config-ownership.json and treat that file as the only promise artifact in this workflow. Generated description text may land in draft_description after a skim, which is a weaker bar than signing defaults. Every policy field starts as UNSET and remains invalid for merge until a person replaces the sentinel. The LOG_LEVEL row below is complete; the DATABASE_URL row must not merge with UNSET policy fields.

{
  "version": 1,
  "keys": {
    "DATABASE_URL": {
      "draft_description": "SQLAlchemy connection string used by the API process.",
      "default": "UNSET",
      "secret": "UNSET",
      "environments": "UNSET",
      "deprecation_window": "UNSET",
      "production_support": "UNSET",
      "example": "postgresql://USER:REDACTED@localhost:5432/app",
      "signer": "UNSET",
      "signed_at": "UNSET"
    },
    "LOG_LEVEL": {
      "draft_description": "Process log verbosity for the API worker.",
      "default": "INFO",
      "secret": "no",
      "environments": "all",
      "deprecation_window": "none",
      "production_support": "supported",
      "example": "INFO",
      "signer": "release-owners",
      "signed_at": "2026-09-20"
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

Date stamps belong to the signer, not to the model that proposed the sentence. A completed row is a review record, not proof that production currently matches the cell. Teams that prefer YAML can store the same shape in YAML, because the gate below cares about sentinels rather than about encoding. Keep the promise file in the same repository as the code that introduced the keys.

3. Gate the rendered reference page in CI

The gate has three jobs, and none of those jobs involve judging writing quality or tone. It fails when inventory keys are missing from the ownership file or extra rows lack extractor evidence. It fails when promise fields still contain the UNSET sentinel instead of a signed value. It also fails when a rendered example for a secret key looks like a live connection string.

#!/usr/bin/env python3
"""Fail CI when config promises are unsigned. Worked example, stdlib only."""
from __future__ import annotations

import json
import sys
from pathlib import Path

PROMISE_FIELDS = (
    "default",
    "secret",
    "environments",
    "deprecation_window",
    "production_support",
    "signer",
    "signed_at",
)


def main() -> int:
    inventory = json.loads(Path("build/config-inventory.json").read_text(encoding="utf-8"))
    ownership = json.loads(Path("docs/config-ownership.json").read_text(encoding="utf-8"))
    owned = ownership.get("keys") or {}
    errors: list[str] = []

    inv_keys = {row["key"] for row in inventory}
    for key in sorted(inv_keys):
        if key not in owned:
            errors.append(f"missing ownership row for {key}")
            continue
        row = owned[key]
        for field in PROMISE_FIELDS:
            value = row.get(field, "UNSET")
            if value is None or value == "UNSET" or str(value).strip() == "":
                errors.append(f"{key}.{field} is unsigned")
        secret = str(row.get("secret", "")).lower()
        example = str(row.get("example", ""))
        if secret in {"yes", "true"} and example:
            if "REDACTED" not in example.upper() and "://" in example:
                errors.append(f"{key}.example looks like a live secret")

    for key in sorted(set(owned) - inv_keys):
        errors.append(f"ownership row {key} has no inventory evidence")

    if errors:
        print("config doc ownership gate failed:", file=sys.stderr)
        for item in errors:
            print(f"- {item}", file=sys.stderr)
        return 1
    print(f"config doc ownership gate passed for {len(inv_keys)} keys")
    return 0


if __name__ == "__main__":
    raise SystemExit(main())
Enter fullscreen mode Exit fullscreen mode

Wire both commands in the same CI job so inventory cannot silently diverge from the signed ownership file. A green gate means every extracted key has a signer, not that the signed default is operationally true. Wrong but signed values still need runtime checks, staging review, and incident follow-up after release. Put the commands in the same job so a partial checkout cannot publish yesterday's promises with today's keys.

python3 extract_config_keys.py . > build/config-inventory.json
python3 check_config_docs.py
Enter fullscreen mode Exit fullscreen mode

4. Let a model draft only the draftable columns

After the inventory file exists, a model may propose draft_description text and sanitized examples for each extracted key. It must not write default, secret, deprecation_window, production_support, signer, or signed_at under any prompt. Feed the model the inventory JSON and the matrix, then paste proposals back into draft_description only.

Disclosure: This article was prepared as part of MonkeyCode's product outreach. Free model access and a free server option can run the extractor and draft those description lines. Keep the ownership file in git rather than in a chat transcript after the draft returns. The product does not replace the signer column, and this workflow does not depend on a named model.

A practical prompt constraint is to require JSON fragments that contain only key and draft_description. Reject any payload that includes promise fields, even when the invented values look operationally plausible. Plausible unsigned defaults are the failure mode this gate exists to catch during review. Paste accepted fragments into draft_description and leave every promise field under human control.

5. Render the public page from both files

Keep the renderer boring so it cannot invent policy when a signed cell is missing from disk. Inventory supplies key names and call-site footnotes, while ownership supplies every signed table cell. Draft descriptions may appear as body copy after a human has skimmed them for obvious factual errors. Do not let the renderer fall back to literal_default_in_source when the signed default is absent.

#!/usr/bin/env python3
"""Render docs/config-reference.md from inventory plus signed ownership. Example only."""
from __future__ import annotations

import json
from collections import defaultdict
from pathlib import Path


def render() -> str:
    inventory = json.loads(Path("build/config-inventory.json").read_text(encoding="utf-8"))
    owned = json.loads(Path("docs/config-ownership.json").read_text(encoding="utf-8"))["keys"]
    sites: dict[str, list[str]] = defaultdict(list)
    for row in inventory:
        sites[row["key"]].append(f"{row['path']}:{row['line']}")

    lines = [
        "# Configuration reference",
        "",
        "Defaults, secret handling, and support windows are human-signed.",
        "Call sites are extracted from source on each build.",
        "",
    ]
    for key in sorted(owned):
        row = owned[key]
        lines.extend(
            [
                f"## `{key}`",
                "",
                f"- Default: `{row['default']}`",
                f"- Secret: {row['secret']}",
                f"- Environments: {row['environments']}",
                f"- Deprecation window: {row['deprecation_window']}",
                f"- Production support: {row['production_support']}",
                f"- Example: `{row['example']}`",
                f"- Signer: {row['signer']} on {row['signed_at']}",
                "",
                str(row.get("draft_description", "")),
                "",
            ]
        )
        if key in sites:
            joined = ", ".join(f"`{item}`" for item in sites[key][:8])
            lines.append(f"Call sites: {joined}")
            lines.append("")
    return "\n".join(lines).rstrip() + "\n"


if __name__ == "__main__":
    Path("docs/config-reference.md").write_text(render(), encoding="utf-8")
Enter fullscreen mode Exit fullscreen mode

A missing promise should fail the gate in the previous step, not degrade into an invented contract during render. If you need a markdown table instead of definition lists, build it only after the gate has already passed. Rendering unsigned cells as empty strings is still a publication bug, because readers will fill the silence with whatever default they hope exists.

Test plan for the gate

Label the following cases as a test plan you can run without production traffic or customer data. The cases distinguish inventory drift from unsigned policy, which ordinary markdown snapshot tests rarely catch. If the team only diffs final HTML, unsigned defaults can hide inside an otherwise stable reference page. Run them on every change to the extractor, the gate, or the ownership schema itself.

  1. Add a new os.getenv("NEW_FLAG") call and confirm CI fails for a missing ownership row.
  2. Add the row with every promise field set to UNSET and confirm the gate still fails.
  3. Sign the promise fields, rerun the job, and confirm the rendered page contains the signed default.
  4. Put a live-looking DATABASE_URL example without REDACTED and confirm the secret check fails.
  5. Add an ownership key that the extractor cannot find and confirm the extra-row check fails.
  6. Change only draft_description and confirm the gate stays green, because copy edits are not promises.

These six cases are more informative than a single golden-file test of docs/config-reference.md. A golden file can freeze an unsigned default simply because nobody edited the surrounding headings. The gate asserts the ownership invariant even when the rendered markdown looks unchanged to a casual diff.

Limitations

This extractor covers a narrow Python pattern and will miss dynamically composed key names at runtime. Teams that load configuration from remote stores need another inventory source, or the gate will be incomplete. Literal defaults in source can disagree with Helm values, and the ownership file is where that conflict is resolved. Do not treat a green CI job as proof that production currently uses the signed default.

The gate cannot prove that a signed default is operationally true in every deployed environment. It only proves that a named signer accepted the cell before the reference page was rendered. Drafted descriptions can still be wrong in quieter ways, so they need a human pass before publication. Do not publish inventory call sites if those paths reveal internal hostnames your threat model treats as sensitive.

Who should not use this approach

Skip this workflow if a single operator owns every config key and already writes the reference by hand. Skip it if your configuration surface is already published through a reviewed schema such as OpenAPI or JSON Schema. Skip it if you cannot name a signer, because the ownership file then becomes theater instead of a gate. Those teams will pay the ceremony cost without reducing unsigned-promise risk in the published page.

Security-sensitive secret documentation should not be drafted on a shared server when examples might include real connection strings. Keep those examples synthetic, and keep the ownership file in the same private repository as the code. Teams that want a model to invent support windows should not use this method at all. Inventing support windows is the behavior the gate is designed to reject before merge.

Closing

Configuration reference pages stay honest when inventory is compiled and promises are signed as separate artifacts. A model can draft descriptions after the extractor has listed every key from source. A human still owns defaults, secret flags, environments, deprecation windows, and production support. Keep the ownership file in version control, regenerate inventory in CI, and refuse to merge UNSET cells.

Top comments (0)