Customer-facing error documentation fails when generated class names leak into signed recovery copy without a review gate. A safer workflow extracts exception types and HTTP mappings into a catalog, then allows a model to draft only technical summaries from docstrings. Humans must sign severity, retry class, and recovery wording before those rows can merge into the public error reference. Unsigned rows may exist in a generated index for engineers, but they must not appear in user guides, status pages, or support macros.
Why mixed ownership produces stale recovery copy
Most services already encode a machine-readable error surface through exception classes, error codes, and HTTP status attributes. Documentation authors then rewrite those facts in Markdown, and the two representations drift after the next handler refactor. Models worsen that drift when they invent recovery advice from a class name without a signed source for retry semantics. The resulting page looks complete, yet it can promise refunds, retries, or SLAs that engineering never approved.
The failure is visible in review comments long before any analytics dashboard shows a documentation defect rate. Reviewers repeatedly flag user-visible sentences that over-promise compensation, retry behavior, or incident communication windows. A catalog with explicit ownership columns turns that qualitative pain into a merge gate that CI can enforce. Missing signatures fail the build, and generated summaries are forbidden from overwriting fields a human already signed.
Ownership split: extracted columns versus signed columns
Treat every error row as a record that keeps extracted fields and obligation fields in separate namespaces. The extracted namespace may be regenerated on every commit because the abstract syntax tree is the source of truth. The obligation namespace may receive a model proposal, but it stays unpublished until a human sets signed_by. If a column can be derived from source, regeneration should win; if it changes customer obligations, regeneration must lose.
| Column | Source of truth | Model may draft | Human must own | Public merge without signature |
|---|---|---|---|---|
error_code |
class constant | no | uniqueness | engineer index only |
exc_class |
AST class name | no | n/a | allowed |
http_status |
class attr or handler map | no | public contract | engineer index only |
technical_summary |
docstring | yes, compress only | correct the docstring | engineer index only |
user_message |
not in source | propose only | yes | blocked |
retryable |
not in source unless attr | propose only | yes | blocked |
severity |
not in source unless attr | propose only | yes | blocked |
recovery_steps |
not in source | propose only | yes | blocked |
signed_by |
review record | no | yes | blocked |
The table is the catalog contract rather than an editorial preference, and CI should encode it as a schema check. Extracted columns rebuild from the AST on each commit, while obligation columns remain frozen until a named reviewer updates them. That split also gives models a narrow job: draft technical_summary from a docstring, never mint retry policy from a class name.
Proposed extractor: exception classes to catalog JSON
The following Python is a proposed, unexecuted example that walks a package and emits catalog rows for exception subclasses. It copies previously signed columns forward so regeneration cannot blank user_message, severity, retryable, or recovery_steps. Run it only after you point --root at a real package and inspect the JSON diff in an ordinary pull request.
#!/usr/bin/env python3
"""Proposed extractor: exception classes -> error catalog JSON. Unexecuted example."""
from __future__ import annotations
import argparse
import ast
import json
from pathlib import Path
from typing import Any
SIGNED_FIELDS = (
"user_message",
"retryable",
"severity",
"recovery_steps",
"signed_by",
)
class ExceptionVisitor(ast.NodeVisitor):
def __init__(self, module: str) -> None:
self.module = module
self.rows: list[dict[str, Any]] = []
def visit_ClassDef(self, node: ast.ClassDef) -> None:
bases = [self._name(base) for base in node.bases]
if not self._looks_like_error(node.name, bases):
self.generic_visit(node)
return
docstring = ast.get_docstring(node) or ""
error_code, http_status = self._class_constants(node)
self.rows.append(
{
"error_code": error_code or node.name,
"exc_class": f"{self.module}.{node.name}",
"bases": bases,
"http_status": http_status,
"technical_summary": " ".join(docstring.split())[:240],
"docstring_present": bool(docstring.strip()),
"user_message": "",
"retryable": None,
"severity": "",
"recovery_steps": "",
"signed_by": "",
}
)
self.generic_visit(node)
@staticmethod
def _name(expr: ast.expr) -> str:
if isinstance(expr, ast.Name):
return expr.id
if isinstance(expr, ast.Attribute):
return expr.attr
return type(expr).__name__
@staticmethod
def _looks_like_error(name: str, bases: list[str]) -> bool:
tokens = set(bases) | {name}
return any(
token.endswith(("Error", "Exception", "Fault"))
or token in {"Exception", "BaseException"}
for token in tokens
)
@staticmethod
def _class_constants(node: ast.ClassDef) -> tuple[str | None, int | None]:
code = None
status = None
for stmt in node.body:
if not isinstance(stmt, ast.Assign) or len(stmt.targets) != 1:
continue
target = stmt.targets[0]
if not isinstance(target, ast.Name):
continue
value = stmt.value
if target.id in {"code", "error_code"} and isinstance(value, ast.Constant):
if isinstance(value.value, str):
code = value.value
if target.id in {"http_status", "status_code"} and isinstance(value, ast.Constant):
if isinstance(value.value, int):
status = value.value
return code, status
def merge_signed(old_rows: list[dict[str, Any]], new_rows: list[dict[str, Any]]) -> list[dict[str, Any]]:
previous = {row["exc_class"]: row for row in old_rows}
merged: list[dict[str, Any]] = []
for row in new_rows:
prior = previous.get(row["exc_class"], {})
for field in SIGNED_FIELDS:
if field in prior:
row[field] = prior[field]
merged.append(row)
return merged
def parse_module(path: Path, module: str) -> list[dict[str, Any]]:
tree = ast.parse(path.read_text(encoding="utf-8"), filename=str(path))
visitor = ExceptionVisitor(module)
visitor.visit(tree)
return visitor.rows
def main() -> None:
parser = argparse.ArgumentParser()
parser.add_argument("--root", type=Path, required=True)
parser.add_argument("--manifest", type=Path, required=True)
parser.add_argument("--out", type=Path, required=True)
args = parser.parse_args()
prefixes = [
line.strip()
for line in args.manifest.read_text(encoding="utf-8").splitlines()
if line.strip() and not line.startswith("#")
]
extracted: list[dict[str, Any]] = []
for path in args.root.rglob("*.py"):
rel = path.relative_to(args.root).with_suffix("")
module = ".".join(rel.parts)
if not any(module == pfx or module.startswith(pfx + ".") for pfx in prefixes):
continue
extracted.extend(parse_module(path, module))
old_rows: list[dict[str, Any]] = []
if args.out.exists():
old_rows = json.loads(args.out.read_text(encoding="utf-8")).get("rows", [])
payload = {"version": 1, "rows": merge_signed(old_rows, extracted)}
args.out.write_text(json.dumps(payload, indent=2) + "\n", encoding="utf-8")
if __name__ == "__main__":
main()
Persist the catalog as docs/errors/catalog.json beside signed overlay fields rather than inside generated Markdown. Markdown renderers should read the JSON, print extracted columns freely, and omit any row that lacks signed_by. That rendering rule is the public-docs equivalent of a database view with a filter on signature state.
python tools/extract_error_catalog.py \
--root src/payments \
--manifest docs/errors/modules.txt \
--out docs/errors/catalog.json
A manifest keeps internal helpers out of the public catalog. The file is a proposed example, not evidence from a particular production repository.
# docs/errors/modules.txt
payments.errors
payments.api.http_errors
Numbered workflow for draft, sign, and merge
The sequence below is a documentation pipeline, not a prompt collection, and each step has a checkable output.
1. Freeze the public exception surface
Name the modules that constitute the public error surface, and keep internal helper exceptions out of the catalog root. Private exceptions used only in tests should not receive customer recovery copy, because that copy would freeze an unstable type. Record the allowed module prefixes in a manifest file so the extractor cannot silently index a new internal package.
2. Extract identity fields from the AST
Run the extractor against those prefixes and write docs/errors/catalog.json as the extracted output for the commit. Diff the JSON in the pull request and treat added classes as unsigned rows, not as ready documentation. Deleted classes should remain in an archive list so public codes are not silently removed from historical references.
3. Draft technical summaries only from docstrings
When a docstring exists, a model may compress it into technical_summary at a maximum of two hundred forty characters. Disclosure: This article was prepared as part of MonkeyCode's product outreach. Teams can draft those summaries with MonkeyCode's free model access and run the extractor on the free server option. Signed recovery columns still live in the reviewed catalog file, not in model output or server scratch space. If the docstring is empty, leave technical_summary blank and fail a warning rather than asking a model to invent meaning from the class name.
4. Humans sign obligation columns
A reviewer fills user_message, retryable, severity, and recovery_steps, then sets signed_by to a real reviewer identity. Retryable must be a boolean with an explicit source, such as an idempotency guarantee or a documented client replay rule. Severity must match the paging policy already used in operations, not a dramatic reading of the exception class name. Recovery steps must describe an action the caller can take, and they must not invent credits, SLAs, or legal remedies.
The fixture below is a labeled example for tests and renderers. It is not a claim about a live payments system.
{
"version": 1,
"rows": [
{
"error_code": "PAY_CONFLICT",
"exc_class": "payments.errors.PaymentConflictError",
"http_status": 409,
"technical_summary": "Raised when two captured intents share the same idempotency key.",
"user_message": "This payment was already processed. Refresh the order before retrying.",
"retryable": false,
"severity": "warn",
"recovery_steps": "GET the order; if status is captured, stop retries; otherwise open a support ticket with the intent id.",
"signed_by": "docs-oncall"
}
]
}
5. Render two views from one catalog
Generate an engineer index that includes unsigned rows so missing signatures remain visible during ordinary code review. Generate the public error reference only from rows where signed_by is present and obligation columns are complete. Do not let a docs site template print class names as user-facing titles when a signed user_message exists.
# tools/render_error_docs.py
# Proposed, unexecuted example.
from __future__ import annotations
import argparse
import json
from pathlib import Path
def render_public(rows: list[dict]) -> str:
lines = [
"<!-- generated from docs/errors/catalog.json; do not sign this file -->",
"# Error reference",
"",
"Only signed rows are published. Retry and recovery wording is human-owned.",
"",
]
published = [row for row in rows if row.get("signed_by")]
for row in sorted(published, key=lambda item: item["error_code"]):
retry = "yes" if row["retryable"] else "no"
lines.extend(
[
f"## {row['error_code']}",
"",
row["user_message"],
"",
f"- HTTP status: {row.get('http_status') or 'unspecified'}",
f"- Retryable: {retry}",
f"- Severity: {row['severity']}",
"",
row["recovery_steps"],
"",
]
)
return "\n".join(lines) + "\n"
def render_internal(rows: list[dict]) -> str:
lines = ["# Engineering error index", "", "Unsigned rows are visible here only.", ""]
for row in sorted(rows, key=lambda item: item["exc_class"]):
flag = "signed" if row.get("signed_by") else "UNSIGNED"
lines.append(f"- `{row['exc_class']}` ({row['error_code']}) — {flag}")
return "\n".join(lines) + "\n"
def main() -> None:
parser = argparse.ArgumentParser()
parser.add_argument("--catalog", type=Path, required=True)
parser.add_argument("--public", type=Path, required=True)
parser.add_argument("--internal", type=Path, required=True)
args = parser.parse_args()
rows = json.loads(args.catalog.read_text(encoding="utf-8"))["rows"]
args.public.write_text(render_public(rows), encoding="utf-8")
args.internal.write_text(render_internal(rows), encoding="utf-8")
if __name__ == "__main__":
main()
python tools/render_error_docs.py \
--catalog docs/errors/catalog.json \
--public docs/errors/public.md \
--internal docs/errors/engineering.md
6. Enforce the gate in CI
Fail the build when public Markdown contains an exception class that is unsigned in catalog.json for this commit. Fail the build when a signed row changes extracted identity fields without a human note, because the contract may have shifted. Allow unsigned rows to merge only into the engineer index, never into the path that publishes customer documentation.
Gate tests that keep unsigned recovery out of public docs
The tests below are proposed examples. They encode the ownership split as assertions instead of reviewer memory.
# tests/test_error_catalog_gate.py
# Proposed, unexecuted example.
import json
from pathlib import Path
CATALOG = Path("docs/errors/catalog.json")
PUBLIC_MD = Path("docs/errors/public.md")
OBLIGATION = ("user_message", "severity", "recovery_steps")
def load_rows():
payload = json.loads(CATALOG.read_text(encoding="utf-8"))
return payload["rows"]
def test_signed_rows_have_obligation_fields():
for row in load_rows():
if not row.get("signed_by"):
continue
for field in OBLIGATION:
assert row.get(field), f"{row['exc_class']} missing {field}"
assert row["retryable"] in (True, False), f"{row['exc_class']} retryable unset"
def test_public_markdown_omits_unsigned_classes():
public = PUBLIC_MD.read_text(encoding="utf-8")
for row in load_rows():
if row.get("signed_by"):
assert row["error_code"] in public
continue
assert row["exc_class"] not in public
assert row["error_code"] not in public
def test_extracted_identity_fields_are_present():
for row in load_rows():
assert row.get("exc_class")
assert row.get("error_code")
def test_empty_docstring_does_not_invent_summary():
for row in load_rows():
if row.get("docstring_present") is False:
assert row.get("technical_summary") in ("", None)
pytest tests/test_error_catalog_gate.py
Wire the tests into the same job that renders documentation, so a green docs build cannot hide an unsigned public row. A renderer that prints class names for unsigned rows belongs only on the engineering index, where missing signatures are the point of the page.
Limitations and who should skip this workflow
This workflow assumes exception classes are a stable public contract, which is false for many rapidly prototyped internal tools. Docstrings are often stale or humorous, and a model will faithfully compress jokes into technical_summary if you allow it. HTTP status attributes on classes can disagree with the status the handler actually returns, so mapping still needs a human check. The catalog does not replace an incident runbook, a status-page taxonomy, or a legally reviewed terms-of-service clause.
Class renames appear as a deletion plus an insertion, which drops signed columns unless reviewers pass an explicit rename map. AST extraction also misses errors declared only as string codes in middleware, protobuf enums, or OpenAPI components. If those formats are your contract, reuse the extracted-versus-signed column split, and replace the walker rather than forcing Python class names into the catalog.
Regulated products, payment user notices, and health-related messaging should not take recovery_steps from a draft even after a casual thumbs-up. Teams without a named documentation owner will accumulate unsigned rows and then disable the CI gate under release pressure. Skip the extractor if you do not yet have a public error module and are still renaming exceptions every week.
Skip model drafts if your docstrings contain secrets, customer identifiers, or unreleased product names that should not leave the repository. Skip public rendering if legal or support must approve every user_message through a ticket, because the catalog would duplicate that queue without authority. In those organizations the catalog can still feed an engineering index, while customer copy stays on the existing approval path.
Keep the rule small: extracted columns come from source, obligation columns come from signed humans, and models may only compress existing docstrings. When those three jobs stay separate, the error reference can regenerate after refactors without rewriting promises the company did not make.
Top comments (0)