Skip to content

Structured error codes

Citizenry exposes HTTP APIs (identity, vault, planned salon) that third-party agents and internal services consume. Errors from these surfaces need to be:

  1. Machine-stable - clients dispatch on a short identifier.
  2. Self-describing enough to be grep-able in logs and to hint at the responsible subsystem without a lookup table.
  3. Catalogued - every emitted code resolves to a documented page so a debugging caller can land on an authoritative explanation.

Earlier the codebase used an ad-hoc ERR-P01-S##-NNNN scheme with numeric service codes (S01..S05). The numeric service slot proved opaque: developers had to memorise that S03 is vault, and grepping a log line for “vault” returned no hits. Meanwhile no public catalog existed, so the code string in responses was a dead-end for callers.

  • Multi-product workspace. citizenry-id is one of several products (planned: endue-salon, endue-ai); codes must namespace by product without collisions.
  • HTTP-first surface. The chosen scheme should plug into RFC 9457 Problem Details without invention.
  • Open source consumption. External RPs may not have access to our internal numbering registries; codes should self-document.
  • Lint-enforceable. A regex and a code↔HTTP-status table must be expressible so CI can fail bad PRs automatically.
  • Append-only. Codes are part of the public API contract; once issued, they cannot be re-meant.
  1. All-numeric tokens (ERR-P01-S03-3001) - status quo. Stable but opaque; service slot is unreadable without a side table.
  2. All-slug tokens (ERR-CTZ-VLT-3001) - most readable; requires stabilising product slugs early.
  3. Hybrid: numeric product + slug service (ERR-P01-VLT-3001) - readable service slot, stable product numeric (products are few and slow to add).
  4. Stripe-style slug only (vault_not_found) - abandons hierarchy; weak for a multi-service monorepo.
All-numericAll-slugHybrid (chosen)Stripe-style
Grep readability
Product namespacing stability✓ (if regulated)
Migration cost from current0highlowhigh
Regex enforceabilityweak
Self-locates the subsystem

We adopt the hybrid scheme:

ERR-P##-XXX-NNNN
  • P## - 2-digit numeric product (P01 citizenry-id, P02 endue-salon, …).
  • XXX - 3–4-letter uppercase service slug (IDT, VLT, MAIL, WLT, TNT, AUTH, SLN, …).
  • NNNN - 4-digit sequence; thousands digit encodes a fixed category whose allowed HTTP status set is enforced by CI.

The full registry, category table, and procedural rules live in docs/reference/error-codes/guideline.md.

Every code emitted by the server MUST have a Markdown file under docs/reference/error-codes/{code}.md derived from templates/error-code.md. The /docs create error-code skill scaffolds it from the template.

The error envelope is RFC 9457 Problem Details with a type URI of https://citizenry.id/errors/{code}. This page becomes the public catalog.

  • Each code points at a documented page; external integrators can resolve errors without internal knowledge.
  • Service slot is readable in logs and tracebacks (-IDT-, -VLT-, -SLN-).
  • CI can fail PRs that introduce undocumented codes or mismatched HTTP statuses.
  • TypeSpec → OpenAPI surface gains structured type URIs.
  • One-time migration: existing S## codes are deprecated and dual-emitted during a transition window.
  • Slug registry needs governance - a typo’d slug at PR time is permanent unless caught in review.
  • Numeric product code retains opacity (P01 does not say “citizenry-id”), but products are few and stable, so the cost is small.
  1. PR-1 - Land docs/reference/error-codes/guideline.md, templates/error-code.md, templates/adr.md, this ADR, and CODE_OF_CONDUCT.md. (this change)
  2. PR-2 - Update packages/spec/common/errors.tsp BaseError to add the RFC 9457 type field. Adjust response builders to fill type from ${ISSUER_HOST}/errors/${code}.
  3. PR-3 - Introduce packages/spec/common/error-catalog.tsp as the single source of truth. Backfill currently-thrown codes.
  4. PR-4 - Add packages/spec/scripts/lint-errors.ts and a root-level lint:errors pnpm script; wire into CI.
  5. PR-5 - Migrate raise-sites from ERR-P01-S##-NNNN to ERR-P01-XXX-NNNN; dual-emit during one release cycle via catalog aliases.
  6. PR-6 - Publish the docs site (apps/web route /errors/[code]) consuming the catalog.
  • A subsequent ADR will lock down the slug registry process (who can mint a slug, how a clash is resolved).
  • Re-evaluate after one release cycle: are deprecated S## codes still observed in client traffic? If yes, extend the alias window.