ADR 002 Exit codes and diagnostics - nekrozis/qtogo GitHub Wiki

ADR-002: Exit codes and diagnostics

Status: Accepted (2026-10-06).

Context

The exit code and the --json failure document are the parts of the CLI that automation reads. Both are therefore an interface: a script that branches on "version not found" must keep meaning that, and a CI job must be able to tell a mistyped version from an unreachable server. The reference implementation has no such contract; this project needs one.

Decision

1. The exit codes

Code Meaning
0 success
1 internal error — unclassified
2 command-line usage error
3 version or package not found
4 network or mirror error
5 integrity or security error
6 extraction or filesystem error
7 relocation error
8 configuration error
9 authentication error
130 interrupted by the user
  • 1 is "internal", not "bug". The code is read by users and scripts, so it cannot promise "a developer made a mistake": resource exhaustion, an unexpected state from a dependency, or an unforeseen failure all land here. What it does promise is that the failure did not fit any other category.
  • 3 and 4 stay separate. Otherwise a typo and an outage look the same, and a caller cannot choose between "suggest a version" and "retry". A request that the server answers with "no such package" is 3; anything where we could not learn whether the package exists is 4.
  • 5 is integrity or security: a checksum that does not match, a signature that fails, or an archive whose paths would escape the destination. In each case the bytes arrived but cannot be trusted.
  • 6 is extraction or filesystem, 7 is relocation. 7 means the archives were extracted correctly and the installation still is not usable — a prefix rewrite or a qt.conf that could not be written. Keeping them apart says whether the download is suspect or the post-processing is.
  • 8 and 9 are reserved now, used later for invalid configuration and for credential failures. Config and credentials are not edge cases in this project's roadmap, and defining them later would be a contract change.
  • Codes 10 and above are not pre-defined. A new one is added when the domain actually needs it.

2. One authority

internal/exitcode.Classify is the only code that maps a failure to a number, and it only trusts errors that implement CodedError:

func Classify(err error) int // nil -> OK; CodedError -> its code; else Internal

Everything the program raises is an *errs.Error carrying a category-derived exit code, a stable machine code and a phase. An error that did not come from that type is reported as internal, which is a signal to fix the taxonomy rather than to guess.

*errs.Error is the canonical implementation of CodedError; other implementations are not part of the public error taxonomy. A second type that reports its own exit code would put the mapping in two places, which is exactly what this decision exists to prevent.

3. The JSON failure document

Written to stderr, always the same shape:

{"ok":false,"error":{"code":"usage.unknown_command","phase":"parse","message":"unknown command \"list-qt\"","suggestion":"run \"qtogo help\" to see the available commands"}}
  • Field names are frozen; suggestion is omitted when empty.
  • --json is honoured even when parsing is what failed, because the automation that asked for JSON still has to read the failure. This is why the mode is determined by scanning the raw command line.
  • Text mode is for people: Error: <message> plus a hint: line when there is a suggestion.

This document is the JSON representation of a failure only. Successful command output has its own contract, defined by the command that produces it — this envelope is not a wrapper every --json document has to fit inside.

4. Panics

A panic is reserved for a broken internal invariant — a rule table that is empty when it cannot be, a node the dispatcher cannot reach. Anything a user or the environment can cause is returned as an error, because a stack trace is not a diagnostic.

Consequences

  • Adding a category means adding a code and a row here; the exit-code test pins the numbers so an accidental change fails the build.
  • Callers of the library never see a raw error from the top level: Main renders it and returns the code.
  • Because --json is decided before parsing, a malformed command line still produces a document rather than a plain-text surprise.

Alternatives considered

  • Reuse a general-purpose scheme (0/1/2, or sysexits). Rejected: it cannot express "not found" versus "network", which is the distinction this tool's callers most need.
  • Make 1 mean "programming bug". Rejected: it makes an unclassified runtime condition indistinguishable from a defect in a script's eyes.
  • Put the code in the JSON document only. Rejected: $? is what shell and CI read first, and it must carry the same meaning.
⚠️ **GitHub.com Fallback** ⚠️