Skip to content

rtb-error process exit-code attachment

Status: APPROVED — GTB-parity gap from the v0.22.0 audit (Phase 2, §A; shared-subsystem drift). RTB exits 1 on every error; GTB threads custom process exit codes (pkg/errorhandling/exitcode.go:23, WithExitCode/ExitCode, e.g. 128+signum). This adds the capability the RTB way — a value attached to an error and read once at the edge, not a re-introduction of the rejected ErrorHandler.check() funnel (CLAUDE.md anti-patterns; rtb-error/src/lib.rs:6).

1. Surface

/// Attach a process exit code to any error/report.
pub trait WithExitCode {
    fn with_exit_code(self, code: u8) -> ExitCoded<Self> where Self: Sized;
}

/// Carries an exit code alongside a diagnostic; renders transparently.
pub struct ExitCoded<E> { pub code: u8, pub source: E }

/// Read the attached code from a Report at the process boundary.
pub fn exit_code_of(report: &miette::Report) -> Option<u8>;
  • ExitCoded<E> implements std::error::Error + miette::Diagnostic transparently (delegates code/help/severity/source to the inner diagnostic) so attaching a code never hides the underlying diagnostic output. WithExitCode is blanket-impl'd for E: Into<Report>/Error.
  • Convenience: miette-style — err.with_exit_code(2)?.

2. Mechanism (read once at the edge)

rtb-cli's Application::run already renders the final Result through the miette hook. Extend that single boundary: when the run returns Err(report), call exit_code_of(&report); if Some(code), after rendering, std::process::exit(code) (or return ExitCode::from(code)); otherwise the existing default (1). No code path other than the boundary inspects the attachment — errors stay values propagated with ?.

exit_code_of recovers the code by downcasting the report's inner error to ExitCoded<_> (miette Report::downcast_ref against the carried type, or a small object-safe ExitCodeCarrier marker trait the struct implements so the boundary can read code without knowing E).

3. Non-goals / boundaries

  • No ErrorHandler / .check() funnelwith_exit_code decorates a value; it does not centralise error handling.
  • No signal-to-code policy baked in (callers choose codes; a 128+signum helper may be added by a future shutdown spec, not here).
  • 0 is not attachable as an "error" code (success is Ok).

4. Testing (TDD, ≥90%)

  • err.with_exit_code(2)exit_code_of returns Some(2); the inner diagnostic's code()/help()/Display are unchanged (transparency).
  • A plain error → exit_code_of returns None (boundary defaults to 1).
  • Nested: with_exit_code through a ?-propagated chain still surfaces at the boundary.
  • An Application::run integration test (or a thin harness) asserts the process exits with the attached code for an ExitCoded error and 1 otherwise.

5. Out of scope

  • Per-command default exit-code maps; signal-derived codes (future).