Error catalogs stay trustworthy only when generated tables never carry retry promises or incident language. A frozen registry can drive status codes, stable titles, and field columns without inventing new error identifiers. Human reviewers must still sign every sentence that asserts timing, paging, or customer impact. The rest of this tutorial shows a compile path, a vocabulary linter, and a decision table you can copy.
The failure mode this workflow targets
Many teams let a chat session rewrite docs/errors.md from memory after each sprint, then watch identifiers drift. The resulting page often invents undocumented statuses, softens 429 copy, or promises retries the service does not perform. Readers treat that page as a contract, so one unsigned sentence can dominate an outage review. Splitting compileable tables from signed promises keeps the catalog current without handing the contract to a model.
This split is documentation work, not model worship, and it does not require a production agent platform. A deterministic script should emit the table. A drafting model may write navigation prose and misread warnings only after that table exists. Named humans still own retry, credit, and paging language, because those sentences move money and sleep schedules.
Decision table: draftable fields versus signed promises
Treat every error-document field as compileable or signed before any generation run starts. Compileable fields are mechanical projections of a hashed registry and must fail CI when they diverge. Signed fields are promises about time, people, or money, and they must not appear in the model worktree. Paste the table below into the repository and enforce it in review.
| Field | Source of truth | Lane | Model may draft? |
|---|---|---|---|
code |
error-registry.json |
compile | yes, exact token |
http_status |
registry | compile | yes, integer only |
title |
registry | compile | yes, verbatim string |
request_id_field |
registry | compile | yes, field name |
example_body |
registry examples | compile | yes, hashed bytes |
| retry timing and backoff | product plus SRE | sign | no |
| customer-visible impact | support plus legal | sign | no |
| page on-call when | incident policy | sign | no |
| SLA credit language | contracts | sign | no |
| sunset and scope notes | owners | sign | no |
A model that fills compile columns from the registry is doing projection work, not authorship of a contract. A model that writes “clients should retry for fifteen minutes” is asserting an operational promise. Keep those outputs in different files with different reviewers, even when one release updates both surfaces.
Step 1: Freeze the error registry and hash the bytes
Export error identifiers from code into a versioned JSON file, then hash that file in the same commit. Do not let a model invent codes, rename titles, or add statuses the service cannot emit. The registry behaves like a lockfile: it changes only through a reviewed code change. Store the hash beside the registry so later jobs can refuse a dirty snapshot.
{
"registry_id": "errors-v3",
"commit": "REPLACE_WITH_GIT_SHA",
"errors": [
{
"code": "rate_limited",
"http_status": 429,
"title": "Too many requests",
"request_id_field": "x-request-id",
"example_body": {"error": "rate_limited", "message": "Too many requests"}
},
{
"code": "upstream_timeout",
"http_status": 504,
"title": "Upstream timeout",
"request_id_field": "x-request-id",
"example_body": {"error": "upstream_timeout", "message": "Upstream timeout"}
}
]
}
Proposed commands, labeled because they are a template rather than a measured production run:
mkdir -p docs/_data docs/errors
# write docs/_data/error-registry.json first, then pin its digest
python3 - <<'PY'
import hashlib, pathlib
p = pathlib.Path("docs/_data/error-registry.json")
digest = hashlib.sha256(p.read_bytes()).hexdigest()
pathlib.Path("docs/_data/error-registry.sha256").write_text(digest + "\n")
print(digest)
PY
git add docs/_data/error-registry.json docs/_data/error-registry.sha256
Leave REPLACE_WITH_GIT_SHA as a placeholder until the exporting commit exists in your history. The compile job should read the hash file first and exit nonzero when JSON bytes no longer match. That check is what stops a drafting pass from inserting a helpful but fictional code.
Step 2: Compile the catalog table without prose promises
Generate only a Markdown table plus the registry identifier, and refuse any free-text column that is not in the JSON. The script below is a proposed compiler you can save as tools/compile_error_catalog.py. It does not call a model, and that is the point of the compile lane.
#!/usr/bin/env python3
"""Compile docs/errors/catalog.generated.md from a hashed registry."""
from __future__ import annotations
import hashlib, json, pathlib, sys
ROOT = pathlib.Path("docs/_data")
REG = ROOT / "error-registry.json"
PIN = ROOT / "error-registry.sha256"
OUT = pathlib.Path("docs/errors/catalog.generated.md")
def main() -> int:
payload = REG.read_bytes()
digest = hashlib.sha256(payload).hexdigest()
pinned = PIN.read_text().strip()
if digest != pinned:
print(f"registry hash mismatch: got {digest}, pinned {pinned}", file=sys.stderr)
return 2
data = json.loads(payload)
lines = [
"<!-- generated; do not sign; do not edit by hand -->",
f"# Error catalog (registry `{data['registry_id']}`)",
"",
"| Code | HTTP status | Title | Request ID field |",
"| --- | --- | --- | --- |",
]
for item in data["errors"]:
lines.append(
f"| `{item['code']}` | {item['http_status']} | {item['title']} | `{item['request_id_field']}` |"
)
lines.append("")
lines.append("Example bodies stay in the registry file; this page only projects columns.")
OUT.write_text("\n".join(lines) + "\n")
print(f"wrote {OUT}")
return 0
if __name__ == "__main__":
raise SystemExit(main())
Run python3 tools/compile_error_catalog.py after every registry change and commit the generated table beside the hash. Reviewers then diff columns, not chat wording. If someone pastes retry advice into this file, the linter in step 4 should fail the build.
Step 3: Draft only navigation prose, on a throwaway workspace
After the table exists, a model may draft docs/errors/how-to-read.draft.md with locator steps and common misreads. It may not draft backoff curves, credit rules, or paging thresholds, even when the prompt sounds careful. Keep the draft file in a workspace that cannot write signed paths. The numbered outline below is the only prompt skeleton this tutorial needs.
- Attach
catalog.generated.mdand refuse any error code absent from that table. - Ask for a short “how to locate a code” section that names the four compile columns.
- Ask for a “common misreads” section that does not mention time, money, or on-call.
- Ban the phrase list in step 4 inside the instructions, then run the linter on the output.
- Move accepted prose into review; never merge draft files that still contain forbidden tokens.
If you need a drafting host without standing up dedicated inference hardware, MonkeyCode’s free model access and free server option can run that draft lane. Disclosure: This article was prepared as part of MonkeyCode's product outreach. The product mention stops there, because the compile script and the signed retry file do not depend on any vendor.
Proposed draft skeleton, which remains unexecuted until you fill it from the table:
# How to read the error catalog (draft)
1. Match `code` before you match the human title.
2. Treat `http_status` as transport, not as a product diagnosis.
3. Correlate logs with the documented request ID field.
4. If a payload code is missing from the table, stop and file a registry gap.
That skeleton is intentionally dull. Dull navigation prose is cheaper to review than a confident incident narrative that nobody signed.
Step 4: Lint generated and draft files for promise vocabulary
A lane split fails when retry language leaks into a generated page through a single adjective. The proposed linter scans compile and draft paths only, and it ignores signed files on purpose. Humans must be allowed to write “retry after 60 seconds” inside the signed document. Models must not be allowed to write it anywhere else.
#!/usr/bin/env python3
"""Fail when compile or draft docs contain signed-promise vocabulary."""
from __future__ import annotations
import pathlib, re, sys
TARGETS = [
pathlib.Path("docs/errors/catalog.generated.md"),
pathlib.Path("docs/errors/how-to-read.draft.md"),
]
PHRASES = [
r"retry after",
r"exponential backoff",
r"we guarantee",
r"\bsla\b",
r"service credit",
r"page on-call",
r"on-call",
r"uptime",
r"within \d+\s*(ms|s|sec|secs|seconds|m|min|mins|minutes|h|hours)",
r"\b99\.\d+",
]
def main() -> int:
failed = 0
combined = re.compile("|" .join(PHRASES), re.I) if False else re.compile("|" .join(PHRASES), re.I)
for path in TARGETS:
if not path.exists():
continue
text = path.read_text()
for i, line in enumerate(text.splitlines(), 1):
match = combined.search(line)
if match:
print(f"{path}:{i}: promise vocabulary {match.group(0)!r}")
failed += 1
return 1 if failed else 0
if __name__ == "__main__":
raise SystemExit(main())
The if False else fragment above is only to keep the compile expression obvious in a short listing; drop it in a real file and compile the pattern once. Wire the script into CI on pull requests that touch docs/errors/**. A red linter is cheaper than a customer quoting generated backoff text during a credit request.
Step 5: Keep retry, paging, and credit copy in a signed file
Create docs/errors/retry.signed.md with named owners, dates, and explicit non-goals. The model worktree should mount that path read-only, or omit it entirely. Reviewers sign this file the same way they sign an incident policy, because the audience will treat it as one.
# Retry and incident copy (human-owned)
Owners: SRE lead, API owner, support lead
Last signed: REPLACE_WITH_ISO_DATE
## Retry
Clients may retry `rate_limited` only after honoring `Retry-After` when present.
Clients must not busy-loop `upstream_timeout`; the service does not promise a wait window here.
## Paging
Page on-call for sustained 504 rates that exceed the internal error budget policy.
Do not page from a single documented example body in the catalog table.
## Non-goals
This file does not add error codes. New codes belong in the frozen registry first.
Replace the date placeholder during human review, not during drafting. If a release changes retry behavior, update this signed file in a pull request that does not also rewrite the generated table by hand. Two small diffs are easier to audit than one mixed narrative.
Step 6: Add a path checker so signed files stay closed
Proposed CI gate, saved as tools/check_doc_lanes.py, which fails when a model-labeled change touches signed paths. Adapt the environment variable to whatever label your pipeline already uses. The checker is boring on purpose, because boring gates survive prompt churn.
#!/usr/bin/env python3
import os, subprocess, sys
SIGNED = {
"docs/errors/retry.signed.md",
"docs/errors/incident-language.signed.md",
"docs/_data/error-registry.json",
"docs/_data/error-registry.sha256",
}
def main() -> int:
if os.environ.get("CHANGE_LANE") != "model-draft":
return 0
diff = subprocess.check_output(["git", "diff", "--name-only", "origin/main...HEAD"], text=True)
blocked = sorted(path for path in diff.splitlines() if path in SIGNED)
if blocked:
print("signed paths changed in a model-draft lane:")
print("\n".join(blocked))
return 3
return 0
if __name__ == "__main__":
raise SystemExit(main())
Run compile, vocabulary lint, and the path checker in that order. A draft can still be wrong in tone after those gates, which is why a human still reads how-to-read.draft.md before promotion. The gates only remove the failure mode where a model silently authors a contract.
Limitations
This workflow assumes you can export a closed error list from code. If handlers return free-form strings, freeze that mess first or the catalog will launder chaos. The vocabulary linter is a denylist, so novel legal phrasing can still slip through until someone adds a pattern. Hash pinning also does not prove the registry matches production traffic; it only proves the docs compiler used the bytes you reviewed.
The draft lane can still hallucinate a “typical client flow” that implies timing without using banned phrases. Reviewers should reject any sentence that would change a support macro if it were true. Unsigned introductory prose is not harmless when it trains readers to expect a behavior the signed file later denies.
Who should not use this approach
Do not use this split if error copy is itself a regulated instrument, such as medical-device fault text or securities incident notices. Those sentences need a qualified author from the first draft, not a later signature stamp. Do not use it if nobody owns retry policy, because the signed file will rot and readers will trust the generated table instead. Do not use a drafting model to invent codes for services that still collapse every failure into HTTP 500.
Teams that already generate catalogs from OpenAPI without customer promises may only need the compiler and the hash pin. Adding a model becomes useful when navigation prose and misread warnings would otherwise lag every registry change. If that drafting host is already in the loop, mount the signed retry directory read-only before the session starts rather than reminding the model in prose.
Top comments (0)