Decisions

ADR-006: Seven canonical error categories, by convention not by type

When a service logs an error, the Phase 5 alerting story needs to group errors into a small, named set of categories so that SLO burn-rate alerts can target "error rate of `upstream_timeout` over the last 15 minutes"…

#006

ADR-006: Seven canonical error categories, by convention not by type

Status: Accepted Date: 2026-04-21

Context

When a service logs an error, the Phase 5 alerting story needs to group errors into a small, named set of categories so that SLO burn-rate alerts can target "error rate of upstream_timeout over the last 15 minutes" without false-positives from unrelated failures (validation rejections, not-found lookups). Without a taxonomy, every service invents its own error labels and dashboards become per-service hand-tuning forever.

The categories must be:

  • Small enough to reason about. A reviewer reading a dashboard needs to hold the set in their head.
  • Separable by alert intent. validation failures and upstream_timeout failures warrant different alerts (one is "the client should retry", the other is "the dependency is sick").
  • Stable across services. A cross-service dashboard comparing error mix only works if every service uses the same names.

The library's observ.Error(err).Category(...).Log(...) chain already exposes this as a first-class step; see gyrum-go pkg/observ/README.md.

Decision

Every ERROR-level log line carries an err_category field. The canonical set is seven values:

Category When
validation Input failed validation before business logic ran.
auth Caller is unauthenticated or unauthorised.
not_found Target resource does not exist.
upstream_timeout Outbound call exceeded its deadline.
upstream_error Outbound call returned non-2xx or a library error.
db_error DB call failed for any reason other than timeout.
internal Unhandled, unexpected, or a violated invariant. Bug class.

The category is free-form at the library boundary — Category("foo") compiles. Conformance is convention, not wall. Services agree to the seven-name set; the library does not enforce it.

Extension requires an issue against gyrum-labs/gyrum-go titled observ: propose new error category "<name>", rationale, and a use site. The bar is "the existing seven forced me into a lie", not "I wish this category existed". See observability-standards.md#error-categories.

Consequences

  • SLO alerts target specific categories. Phase 5 alerts read {service="distill", err_category="upstream_timeout"} and only fire when the targeted class of failure spikes. A burst of validation rejections from a buggy client does not page on-call for a dependency.
  • Cross-service dashboards work. A factory-wide "error mix by category" panel compares like with like across every service.
  • Category conformance is a review question, not a compile error. Call sites with Category("weird_one_off") pass go build. A reviewer catches them. The CI linter mentioned in ADR-008 will flag unknown categories once it ships; until then, the discipline is manual.
  • The set is intentionally short of specific failure modes. There is no network_error separate from upstream_error, no rate_limited separate from upstream_error. When specificity matters, add a field (upstream_status_code, retry_after_ms), don't add a category. Categories are the group-by axis for alerts; fields are the detail.
  • Adding the eighth category requires friction. An issue, a rationale, an ADR amendment. This is deliberate — category growth beyond ~10 values starts to dilute the alert-routing benefit the taxonomy exists for.

Alternatives considered

  • Free-form error strings. Whatever each caller passes in. Rejected: dashboards become per-service bespoke, and {err=~".*timeout.*"} is not a design, it's a regex wish. Alert queries lose precision as the string space diverges across services.
  • Strict Go enum type (observ.CategoryValidation, observ.CategoryAuth, ...). Compile-time enforcement. Rejected: the ergonomics cost at every call site is significant, and library upgrades that add a category break every consumer's build. The library's stated stance — "discipline not wall" — accepts some non-conformance in exchange for ergonomics and upgrade-safety. A lint (future work, tied to ADR-008) is the pragmatic middle ground.
  • HTTP-status-like numeric codes. Dense, grouping-friendly. Rejected: a reader scanning a dashboard should not have to translate 4015 to "validation failed on date range". Human-readable names win.
  • Per-service taxonomies. Each service defines its own. Rejected: kills the cross-service alerting and dashboard story that is the whole point.

Supersedes: none Superseded by: