DEV Community

Avery Lin
Avery Lin

Posted on

Split Troubleshooting Docs Into a Generated Registry and a Human Recovery Map

Troubleshooting pages fail when one prompt writes both error identifiers and customer recovery language. Error identifiers are program facts that should compile from versioned source constants without extra interpretation. Recovery language is a product claim about user harm, so a human must own every sentence. This workflow splits those truths into two files, then stitches them only after an ownership test passes.

The examples below are labeled proposals and unexecuted fixtures, not incident metrics from a production on-call program. They exist so a team can copy the split, run the commands, and see a red test when the files mix. Treat the stitcher as a documentation compiler, not as a writer of customer promises. If a sentence could change product policy, it does not belong in the generated registry.

The mixing problem is a documentation type error

A troubleshooting table usually shows a code, a status, a meaning, and a next action together. That single table mixes compile-time facts with promises about what a customer should do next. Reviewers then hunt for identifier drift instead of judging whether the recovery sentence is still true. The cost is not writing time; it is a false sense that the whole page was reviewed equally.

Generated identifiers should change only when source constants change, which keeps diffs small and mechanical. Recovery sentences should change when product policy changes, which makes diffs rare and argumentative. If both live in one markdown file, git history cannot tell those events apart. A two-file layout restores that distinction before any model is invited to draft a table.

Decision table: draftable facts versus owned claims

Use the table below as a publishing rule rather than a style preference for writers. Anything a compiler could prove from source belongs in the generated registry file. Anything a customer could misunderstand, or an on-call engineer could mis-run, belongs in the human recovery map. Models may draft registry markdown from an extract; they must not draft recovery values.

Field Source of truth Model may draft from extract? Human must own?
Stable error id Source constant or enum Yes, as a table cell No
HTTP status Source constant Yes No
Symbol name Source identifier Yes No
Source path and line Extractor output Yes No
Customer-visible severity Policy review No Yes
Recovery steps Policy review No Yes
“Do not” constraints Policy review No Yes
Last reviewed date Human edit No Yes
Owner alias Team roster No Yes

The rule is intentionally boring, because boring rules survive a busy release week. If a field can be grepped from errors.py, it is not a writing task. If a field tells a user what to do with credentials, money, or data deletion, it is not a drafting task either.

Layout on disk

Keep three inputs and one output, and refuse to let chat transcripts become a fourth input. The source module remains the only origin for identifiers. The recovery map remains the only origin for advice. The stitcher reads both and writes a page that humans read, never a page that humans are expected to edit by hand.

docs/
  errors.py                 # program constants (example fixture)
  extract_errors.py         # compile identifiers to JSON
  recovery.human.yaml       # human-owned recovery map
  stitch_troubleshooting.py # merge + ownership checks
  test_troubleshooting_docs.py
  troubleshooting.md        # output; do not hand-edit
Enter fullscreen mode Exit fullscreen mode

Label the YAML file in the header as human-owned, and label the markdown output as generated. Reviewers should open the YAML when the question is severity or recovery. They should open the extract JSON when the question is whether an identifier still exists.

Step 1: Put stable identifiers in source, not in prose

Start from a small fixture module so the extractor has something deterministic to read. Each error needs a stable id that will survive a rename of the Python symbol. HTTP status belongs beside the id because status is also a program fact, not a documentation opinion.

# docs/errors.py — proposal fixture, not a public API
from dataclasses import dataclass

@dataclass(frozen=True)
class AppError:
    symbol: str
    stable_id: str
    http_status: int

AUTH_TOKEN_EXPIRED = AppError(
    symbol="AUTH_TOKEN_EXPIRED",
    stable_id="err.auth.token_expired",
    http_status=401,
)
RATE_LIMITED = AppError(
    symbol="RATE_LIMITED",
    stable_id="err.http.rate_limited",
    http_status=429,
)
INVOICE_ALREADY_VOIDED = AppError(
    symbol="INVOICE_ALREADY_VOIDED",
    stable_id="err.billing.invoice_already_voided",
    http_status=409,
)

CATALOG = (
    AUTH_TOKEN_EXPIRED,
    RATE_LIMITED,
    INVOICE_ALREADY_VOIDED,
)
Enter fullscreen mode Exit fullscreen mode

Do not store recovery sentences on this dataclass. A default string on the class becomes a back door for generated advice. Keep the module boring so the extractor cannot accidentally export a paragraph.

Step 2: Extract a JSON registry the compiler can diff

The extractor should emit identifiers, statuses, and source locations, then stop. It should not guess English. JSON is the reviewable artifact for this step because a markdown table invites someone to “improve wording” in the same file.

# docs/extract_errors.py — proposal
import json
from pathlib import Path
from errors import CATALOG

def extract():
    rows = []
    for err in CATALOG:
        rows.append(
            {
                "stable_id": err.stable_id,
                "symbol": err.symbol,
                "http_status": err.http_status,
                "origin": "docs/errors.py",
            }
        )
    rows.sort(key=lambda r: r["stable_id"])
    return rows

if __name__ == "__main__":
    Path("docs/registry.extract.json").write_text(
        json.dumps(extract(), indent=2) + "\n",
        encoding="utf-8",
    )
Enter fullscreen mode Exit fullscreen mode

Run the extract in isolation and commit the JSON if your team wants a readable diff of identifier churn. Some teams prefer to treat the JSON as a build product only. Either choice is fine, as long as recovery copy never appears in that file.

python docs/extract_errors.py
python -c "import json; print(len(json.load(open('docs/registry.extract.json'))))"
Enter fullscreen mode Exit fullscreen mode

Step 3: Keep recovery copy in a human YAML map

Write one record per stable_id, and make missing keys a failing test rather than a blank cell. Severity, owner, last reviewed date, and recovery text are claims. They should fail closed when an engineer adds a new constant and forgets the map.

# docs/recovery.human.yaml
# HUMAN-OWNED. Do not generate. Do not paste model output here.
err.auth.token_expired:
  owner: identity-docs
  severity: customer-visible
  last_reviewed: "2026-09-16"
  recovery: >-
    Prompt the user to sign in again. Do not send a replacement token
    by email, chat, or ticket comment.
err.http.rate_limited:
  owner: api-docs
  severity: customer-visible
  last_reviewed: "2026-09-16"
  recovery: >-
    Retry with the documented backoff. Do not raise the client timeout
    as a workaround for a 429.
err.billing.invoice_already_voided:
  owner: billing-docs
  severity: operator-only
  last_reviewed: "2026-09-16"
  recovery: >-
    Show the existing void state. Do not issue a second void, credit,
    or refund from this error alone.
Enter fullscreen mode Exit fullscreen mode

Notice the recovery sentences include refusals, not only happy-path clicks. Refusals are the part a model most often invents or omits. They are also the part that causes real harm when wrong, which is why they stay in the human file.

Step 4: Stitch a page that humans read but do not edit

The stitcher joins extract rows to YAML records by stable_id and writes troubleshooting.md. It must fail if the YAML has extra keys, missing keys, or blank recovery. It must also refuse any attempt to read recovery from the extract JSON, even if a future extract accidentally grows a message field.

# docs/stitch_troubleshooting.py — proposal
import json
import sys
from pathlib import Path

import yaml

FORBIDDEN_EXTRACT_KEYS = {"recovery", "severity", "owner", "last_reviewed"}

def load_extract(path):
    rows = json.loads(Path(path).read_text(encoding="utf-8"))
    for row in rows:
        overlap = FORBIDDEN_EXTRACT_KEYS.intersection(row)
        if overlap:
            raise SystemExit(f"extract mixed human fields: {sorted(overlap)}")
    return rows

def stitch(extract_path, yaml_path, out_path):
    rows = load_extract(extract_path)
    human = yaml.safe_load(Path(yaml_path).read_text(encoding="utf-8")) or {}
    ids = [r["stable_id"] for r in rows]
    missing = [i for i in ids if i not in human]
    extra = sorted(set(human) - set(ids))
    if missing or extra:
        raise SystemExit(f"recovery map mismatch missing={missing} extra={extra}")
    lines = [
        "<!-- GENERATED: edit recovery.human.yaml and errors.py, then restitch. -->",
        "# Troubleshooting registry",
        "",
        "| Stable id | HTTP | Symbol | Severity | Recovery |",
        "| --- | --- | --- | --- | --- |",
    ]
    for row in rows:
        rec = human[row["stable_id"]]
        for key in ("owner", "severity", "last_reviewed", "recovery"):
            if not str(rec.get(key, "")).strip():
                raise SystemExit(f"blank {key} for {row['stable_id']}")
        recovery = " ".join(rec["recovery"].split())
        lines.append(
            f"| `{row['stable_id']}` | {row['http_status']} | `{row['symbol']}` | "
            f"{rec['severity']} | {recovery} |"
        )
    Path(out_path).write_text("\n".join(lines) + "\n", encoding="utf-8")

if __name__ == "__main__":
    stitch(
        "docs/registry.extract.json",
        "docs/recovery.human.yaml",
        "docs/troubleshooting.md",
    )
Enter fullscreen mode Exit fullscreen mode
python docs/stitch_troubleshooting.py
Enter fullscreen mode Exit fullscreen mode

The generated table is allowed to look plain. Pretty prose is not the goal of this page. The goal is a page whose identifier columns can be rebuilt without rewriting the policy columns.

Step 5: Fail the build when ownership mixes

Add tests that encode the decision table, not tests that snapshot an entire essay. The first test locks the extract schema. The second test locks YAML completeness. The third test forbids recovery-like sentences from appearing in the extract file, where a model draft would otherwise hide.

# docs/test_troubleshooting_docs.py — proposal
import json
import re
import subprocess
import sys
from pathlib import Path

ROOT = Path(__file__).resolve().parent

def test_extract_has_only_program_fields():
    subprocess.check_call([sys.executable, str(ROOT / "extract_errors.py")], cwd=ROOT.parent)
    rows = json.loads((ROOT / "registry.extract.json").read_text(encoding="utf-8"))
    assert rows, "extract was empty"
    allowed = {"stable_id", "symbol", "http_status", "origin"}
    for row in rows:
        assert set(row) <= allowed
        assert re.match(r"^err\.[a-z0-9_.]+$", row["stable_id"])

def test_human_map_covers_extract_and_only_extract():
    subprocess.check_call([sys.executable, str(ROOT / "stitch_troubleshooting.py")], cwd=ROOT.parent)

def test_extract_rejects_recovery_prose():
    sample = [{"stable_id": "err.x", "symbol": "X", "http_status": 400, "recovery": "Ask the user"}]
    (ROOT / "registry.extract.json").write_text(json.dumps(sample), encoding="utf-8")
    proc = subprocess.run([sys.executable, str(ROOT / "stitch_troubleshooting.py")], cwd=ROOT.parent)
    assert proc.returncode != 0
Enter fullscreen mode Exit fullscreen mode
pytest docs/test_troubleshooting_docs.py -q
Enter fullscreen mode Exit fullscreen mode

These tests will not prove that recovery advice is wise. They only prove that advice did not leak into the generated side, and that new identifiers cannot ship with an empty customer action. That is a documentation invariant, which is the only class of proof this workflow claims.

Where a free model and a free server belong

Disclosure: This article was prepared as part of MonkeyCode's product outreach. MonkeyCode's free model access and free server option are relevant only after the extract JSON exists, and only on the generated side of the split. A model may turn registry.extract.json into a draft markdown table of ids, symbols, and statuses. It must not receive recovery.human.yaml as an editable target, and it must not be asked to invent severity.

A free server option is useful as a repeatable place to run extract, stitch, and pytest without treating a laptop as the source of truth. The server is a compiler host for this workflow, not a publisher of policy. If the model draft disagrees with the extract, discard the draft and keep the JSON. If the YAML disagrees with product policy, that is a human review, not a regeneration task.

Do not paste a chat transcript into either file. The extract is generated from source. The recovery map is edited in a reviewable diff. The stitcher is the only allowed merge, which keeps the model outside the ownership boundary even when drafting is convenient.

Limitations

The extractor in this article reads an in-process catalog, so it will not discover ad hoc string literals scattered through handlers. Teams that log free-text errors without stable ids will get a false sense of coverage. The stitcher also does not localize recovery copy, and it does not know whether a severity label matches an actual page or pager policy.

Passing tests does not mean the recovery sentence is legally or operationally correct. It means the sentence has an owner and a date, and that it was not emitted from the identifier compiler. Stale last_reviewed values can still ship if nobody enforces calendar freshness. Add that check only if the team will honor it; an ignored date column is worse than none.

Who should not use this approach

Skip this split if errors are only unstructured log lines, because there is nothing honest to compile. Skip it for narrative changelogs, marketing pages, and legal terms, which are not identifier catalogs. Skip it for safety-critical medical or aviation procedures, where this stitcher is not a sufficient control. Skip it if the team cannot name an owner for recovery copy, because the YAML will rot into another generated-looking file.

Use it when public troubleshooting already repeats codes that exist in source, and reviewers are wasting time re-checking those codes. The core conclusion stays the same after the commands run: compile the registry, own the recovery map, and fail the build when a model or a merge puts those jobs in one file.

If the split is useful, keep the recovery map in human review even when a free rebuild regenerates the identifier registry.

Top comments (0)