DEV Community

Avery Lin
Avery Lin

Posted on

Emit Config Field Catalogs From JSON Schema, Then Sign Defaults and Secret Classes

Schema-derived configuration tables are trustworthy only for names, types, and required flags, not for production defaults. Any default, secret classification, restart requirement, or deprecation window must sit in a human-signed file before publish. This article gives a small, reproducible pipeline that extracts a field catalog from JSON Schema and refuses unsigned operational columns. The draft lane may fill structure; the signature lane owns every claim that can change a running system.

The failure mode this workflow targets

Many services already keep a JSON Schema beside the process that loads configuration at boot time. Teams then paste that schema into a wiki and treat default as a production recommendation, which is a category error. Schema defaults exist to make parsers succeed, not to declare a safe operating point for a cluster. A missing secret flag on a token field is worse than a missing description, because the published page becomes an accidental credential map.

JSON Schema remains a useful inventory of paths and types when you treat it as a compiler input. The JSON Schema specification defines validation vocabulary, not operational policy, support windows, or disclosure classification rules. Mixing those layers in one generated page is how unsigned claims leak into customer-facing documentation.

Draft lane versus signature lane

The model, or any deterministic extractor, may emit a catalog row for each schema path it walks. Allowed draft columns are path, json_type, required, enum_values, and schema_description copied verbatim when the schema provides them. Empty descriptions may be drafted as proposals, but they remain unlabeled proposals until a reviewer accepts the wording. None of those draft columns may carry a production default, a secret class, or a restart flag.

Humans must own every column that changes runtime behavior, customer disclosure, or support obligations for a field. That owned set includes production_default, secret_class, restart_required, deprecation_window, and customer_label for each path. If a row lacks a signed operational record, the publisher must omit the row or fail the build, not guess. Reviewers should treat the signed file as code, with the same owners who already approve configuration loader changes.

The table below is the decision artifact for this pipeline and should be copied into the review checklist. Treat that table as the contract between extractors and reviewers, not as a product comparison or marketing claim. Columns marked fail-if-missing are build breakers, while columns marked always may render from schema data alone.

Column Source Owner Publish rule
path schema pointer extractor always
json_type schema type extractor always
required parent required extractor always
enum_values schema enum extractor always
schema_description schema description extractor copy only
drafted_help model proposal reviewer publish only if accepted
production_default signed ops file human fail if missing
secret_class signed ops file human fail if missing
restart_required signed ops file human fail if missing
deprecation_window signed ops file human fail if missing

Artifact layout

Use a repository directory that keeps unsigned field catalogs completely out of the published documentation tree. The following layout is a proposal for a small service repo, not a measured production standard. Generated JSON belongs under facts, signed YAML belongs under review, and rendered markdown belongs only under out. Do not commit chat transcripts beside the signed operations file, because transcripts are not reviewable contracts.

config-docs/
  schema/app-config.schema.json
  tools/extract_fields.py
  tools/publish_config_ref.py
  tools/test_signed_ops.py
  facts/unsigned_catalog.json
  facts/signed_ops.yaml
  facts/accepted_help.yaml
  out/config-reference.md
Enter fullscreen mode Exit fullscreen mode

Step 1: Keep a schema that lists real keys

Start from the schema the service already validates against, not from a chat summary of typical settings. If the process does not validate, add validation first; documentation extractors cannot invent a field inventory that the binary does not implement. A schema that allows additionalProperties without a documented envelope will hide keys that operators still set in production.

{
  "$schema": "https://json-schema.org/draft/2020-12/schema",
  "$id": "https://example.invalid/app-config.schema.json",
  "title": "AppConfig",
  "type": "object",
  "required": ["listen_addr", "log_level"],
  "properties": {
    "listen_addr": {
      "type": "string",
      "description": "Host and port for the HTTP listener."
    },
    "log_level": {
      "type": "string",
      "enum": ["debug", "info", "warn", "error"]
    },
    "database": {
      "type": "object",
      "required": ["url"],
      "properties": {
        "url": { "type": "string" },
        "max_pool": { "type": "integer", "minimum": 1 }
      }
    },
    "auth": {
      "type": "object",
      "properties": {
        "api_token": { "type": "string" }
      }
    }
  },
  "additionalProperties": false
}
Enter fullscreen mode Exit fullscreen mode

The schema above is an example fixture for tests, not a recommended production configuration surface for real clusters. Replace it with the file your loader already consumes so the catalog cannot drift from boot-time validation. When the loader and the schema disagree, fix the loader contract first and only then regenerate the unsigned catalog. Example hostnames in fixtures must stay clearly fake so later publish tests do not treat them as signed defaults.

Step 2: Extract unsigned catalog rows

The extractor walks the properties tree recursively and records JSON Pointer paths for each leaf field. It copies types, required flags, enumeration lists, and descriptions without rewriting any of those values. It must not write defaults, secret classes, or deprecation dates, even when the schema contains a default keyword. Ignoring schema default is deliberate, because that keyword is a parser convenience rather than an operations promise.

# tools/extract_fields.py
# Proposal: deterministic extractor. Unexecuted against your private schema until you run it.
from __future__ import annotations

import json
import sys
from pathlib import Path
from typing import Any


def walk(node: dict[str, Any], path: str, rows: list[dict[str, Any]]) -> None:
    props = node.get("properties") or {}
    req = set(node.get("required") or [])
    for name, child in props.items():
        child_path = f"{path}/{name}" if path else f"/{name}"
        if child.get("type") == "object" and "properties" in child:
            walk(child, child_path, rows)
            continue
        rows.append(
            {
                "path": child_path,
                "json_type": child.get("type", "unknown"),
                "required": name in req,
                "enum_values": child.get("enum") or [],
                "schema_description": child.get("description") or "",
            }
        )


def main() -> None:
    schema_path = Path(sys.argv[1])
    out_path = Path(sys.argv[2])
    schema = json.loads(schema_path.read_text(encoding="utf-8"))
    rows: list[dict[str, Any]] = []
    walk(schema, "", rows)
    out_path.write_text(json.dumps({"fields": rows}, indent=2) + "\n", encoding="utf-8")


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

Run the extractor as a compile step in CI, not as an interactive chat that can skip paths. The command below writes a disposable catalog that reviewers should never edit by hand. If the catalog is hand-edited, the next schema change will silently fight those edits and republish stale paths.

python3 tools/extract_fields.py \
  schema/app-config.schema.json \
  facts/unsigned_catalog.json
Enter fullscreen mode Exit fullscreen mode

Step 3: Sign operational columns in a separate file

Create facts/signed_ops.yaml by hand during the same review that lands loader or schema changes. Every catalog path must appear in that file, including nested JSON Pointer paths for objects. Secret class values are a closed set of public, sensitive, or secret, with no free-text synonyms allowed. Production defaults for secret fields should be empty and marked as required at deploy time, never pasted as example tokens.

# facts/signed_ops.yaml
# Human-owned. Do not generate this file from a model.
fields:
  /listen_addr:
    production_default: "127.0.0.1:8080"
    secret_class: public
    restart_required: true
    deprecation_window: none
    customer_label: Listen address
  /log_level:
    production_default: info
    secret_class: public
    restart_required: false
    deprecation_window: none
    customer_label: Log level
  /database/url:
    production_default: ""
    secret_class: secret
    restart_required: true
    deprecation_window: none
    customer_label: Database URL
  /database/max_pool:
    production_default: "10"
    secret_class: public
    restart_required: false
    deprecation_window: none
    customer_label: Database pool size
  /auth/api_token:
    production_default: ""
    secret_class: secret
    restart_required: true
    deprecation_window: none
    customer_label: API token
Enter fullscreen mode Exit fullscreen mode

A model may list catalog paths that still lack signatures, which is a coverage reminder rather than a policy draft. It must not invent production_default or downgrade secret to public to make the page look complete. Completeness pressure is the usual reason unsigned defaults appear in generated pages after a quiet model pass. If the signed file is incomplete, fail CI and leave the documentation unpublished until owners fill the gaps.

Step 4: Optional help text, accepted not streamed

When schema_description is empty, a draft model can propose one sentence of help for reviewer acceptance. Store those acceptances in facts/accepted_help.yaml after review, keyed by the same JSON Pointer path. Publishing must read that file, not the latest chat transcript, so wording cannot churn between builds. Rejected proposals stay out of git so they cannot re-enter the publisher through a cached session.

Disclosure: This article was prepared as part of MonkeyCode's product outreach. MonkeyCode's free model access and free server option can host that draft-help pass on a throwaway box. The signed YAML should stay on the machine that already reviews configuration loader changes for the service. The product does not replace the signature file, and this article does not claim model names, quotas, or benchmarked accuracy for those drafts.

If a description already exists in the schema, copy that text into the catalog without a second writer. Do not ask a model to improve a sentence that loader comments already made precise for operators. Improving comments in the schema is a code change, and it should go through the same review as the loader. Help files exist only for gaps, not as a parallel style guide that fights the schema text.

Step 5: Publish only joined, signed rows

The publisher inner-joins catalog paths with signed operations records and writes markdown only for complete rows. Missing signatures fail the build so a partial page cannot ship with guessed operational columns. Secret fields render as a deploy-time placeholder instead of echoing any default that happens to sit in YAML. Customer labels come from the signed file so marketing names cannot be invented during the render pass.

# tools/publish_config_ref.py
# Proposal: join + render. Label output as generated, not as policy.
from __future__ import annotations

import json
import sys
from pathlib import Path

import yaml


def main() -> None:
    catalog = json.loads(Path(sys.argv[1]).read_text(encoding="utf-8"))
    signed = yaml.safe_load(Path(sys.argv[2]).read_text(encoding="utf-8"))
    help_map = yaml.safe_load(Path(sys.argv[3]).read_text(encoding="utf-8")) or {}
    accepted = help_map.get("fields") or {}
    lines = [
        "# Configuration reference",
        "",
        "Generated from JSON Schema plus a human-signed operations file.",
        "Operational columns are not model output.",
        "",
        "| Field | Type | Required | Default | Secret class | Restart | Notes |",
        "| --- | --- | --- | --- | --- | --- | --- |",
    ]
    missing = []
    for row in catalog["fields"]:
        path = row["path"]
        ops = (signed.get("fields") or {}).get(path)
        if ops is None:
            missing.append(path)
            continue
        default = ops["production_default"]
        if ops["secret_class"] == "secret":
            default = "(set at deploy; omitted)"
        note = row["schema_description"] or accepted.get(path, {}).get("text", "")
        lines.append(
            f"| `{path}` | {row['json_type']} | {row['required']} | {default} | "
            f"{ops['secret_class']} | {ops['restart_required']} | {note} |"
        )
    if missing:
        raise SystemExit("unsigned paths: " + ", ".join(missing))
    Path(sys.argv[4]).write_text("\n".join(lines) + "\n", encoding="utf-8")


if __name__ == "__main__":
    main()
Enter fullscreen mode Exit fullscreen mode
python3 tools/publish_config_ref.py \
  facts/unsigned_catalog.json \
  facts/signed_ops.yaml \
  facts/accepted_help.yaml \
  out/config-reference.md
Enter fullscreen mode Exit fullscreen mode

Check the rendered table for secret placeholders before merging the job that publishes docs. If a secret default is visible, the publisher is wrong even when the extractor catalog looks complete. Keep the markdown as a build artifact so pull requests show the joined table rather than a chat paste.

Step 6: Fail the build on unsigned operational claims

A second test should scan the published markdown for secret values that must never appear in the rendered table. This guard catches a future publisher that starts copying schema default keywords into the public table again. It also catches signed paths that drifted away from the schema after a rename that nobody updated. Run the guard in the same CI job that already validates configuration so docs and loader fail together.

# tools/test_signed_ops.py
# Proposal: regression guard for leaked defaults.
from __future__ import annotations

import json
import sys
from pathlib import Path

import yaml


def test_every_catalog_path_is_signed() -> None:
    catalog = json.loads(Path("facts/unsigned_catalog.json").read_text(encoding="utf-8"))
    signed = yaml.safe_load(Path("facts/signed_ops.yaml").read_text(encoding="utf-8"))
    signed_paths = set((signed.get("fields") or {}).keys())
    catalog_paths = {row["path"] for row in catalog["fields"]}
    missing = sorted(catalog_paths - signed_paths)
    extra = sorted(signed_paths - catalog_paths)
    assert missing == [], f"unsigned catalog paths: {missing}"
    assert extra == [], f"signed paths missing from schema: {extra}"


def test_published_markdown_omits_secret_defaults() -> None:
    signed = yaml.safe_load(Path("facts/signed_ops.yaml").read_text(encoding="utf-8"))
    text = Path("out/config-reference.md").read_text(encoding="utf-8")
    for path, ops in (signed.get("fields") or {}).items():
        if ops.get("secret_class") == "secret" and ops.get("production_default"):
            assert ops["production_default"] not in text, path


if __name__ == "__main__":
    test_every_catalog_path_is_signed()
    test_published_markdown_omits_secret_defaults()
    sys.stdout.write("ok\n")
Enter fullscreen mode Exit fullscreen mode

Wire both scripts into the same job that already validates configuration during continuous integration. Documentation that cannot fail CI is commentary, not a generated artifact that operators can trust. A job that only prints warnings will be ignored the first week a new secret field lands in schema. Failing the merge is the mechanism that keeps the signature lane honest over time.

Limitations

The extractor shown here understands only nested object property trees and leaf fields under those objects. It does not expand $ref, allOf, oneOf, patternProperties, or array item schemas, so union types will be under-counted. Teams with composed schemas need a real JSON Schema walker instead of this teaching script for production catalogs. Until that walker exists, list unsupported keywords in the review checklist so silent drops are visible.

Secret classification is not a substitute for secret scanning, vault injection, or redacting runtime dumps. A secret label in YAML does not encrypt values, rotate tokens, or prevent an operator from pasting credentials into the help column. Restart flags are human policy; the extractor cannot see whether a process actually watches for SIGHUP. Drafted help can be fluent and still wrong about units, ranges, or failover behavior under load.

This workflow also assumes one schema file maps to one published page, which breaks when several binaries share overlapping keys. Split catalogs by binary when two processes load different subsets of the same document. Shared keys still need one signed operational record so defaults cannot diverge across pages.

Who should not use this approach

Do not use this pipeline if you lack a schema that the service actually validates. Generating a schema from marketing copy, then documenting that schema, only launders fiction through a compiler. Do not use it for threat models, SLA numbers, retention periods, or legal disclosures, which need counsel and incident owners rather than field extractors. Those claims belong in separately signed documents with named owners and dated review, not in a field table.

Skip the model-drafted help pass when the configuration surface is small enough to comment in schema. A five-field file does not benefit from a second writer sitting between schema comments and the published table. Skip a remote draft server entirely when the schema itself contains examples that look like live hostnames, tokens, or customer identifiers. Redact those examples in the schema fixture before any draft pass leaves the review network.

What to keep in the same review as the loader

Keep signed_ops.yaml in the same review path as the loader code that consumes the schema. Unsigned catalog JSON can be rebuilt on every commit without a human rewrite of structural columns. Published markdown should be a build output, not a hand-edited page that diverges after the next schema change. Operational columns should be attributable to a reviewer, not to whichever draft model happened to run.

Top comments (0)