DEV Community

Avery Lin
Avery Lin

Posted on

Freeze an Error Registry, Compile Catalog Tables, and Sign Every Retry Promise

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"}
    }
  ]
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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())
Enter fullscreen mode Exit fullscreen mode

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.

  1. Attach catalog.generated.md and refuse any error code absent from that table.
  2. Ask for a short “how to locate a code” section that names the four compile columns.
  3. Ask for a “common misreads” section that does not mention time, money, or on-call.
  4. Ban the phrase list in step 4 inside the instructions, then run the linter on the output.
  5. 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.
Enter fullscreen mode Exit fullscreen mode

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())
Enter fullscreen mode Exit fullscreen mode

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.
Enter fullscreen mode Exit fullscreen mode

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())
Enter fullscreen mode Exit fullscreen mode

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)