feat(proto): typed errors (ErrorInfo/ErrorReason) via Status details #342

Merged
KofTwentyTwo merged 2 commits from story/ws-a1-typed-errors into dev 2026-07-31 19:12:45 +00:00
KofTwentyTwo commented 2026-07-31 19:11:13 +00:00 (Migrated from github.com)

Closes #273 (WS-A1) — the first story in the serial proto queue. Establishes the typed-error pattern for the whole nuncio.v1 contract + CLI.

What this adds

  • Proto (nuncio.v1) — a frozen, machine-readable error taxonomy:
    • enum ErrorReason (ERROR_REASON_UNSPECIFIED=0, plus VALIDATION_FAILED, ACCOUNT/RULE/MESSAGE/EVENT/CONTACT_NOT_FOUND, ALREADY_EXISTS, NOT_CONFIGURED, BACKEND_UNREACHABLE, AUTH_REQUIRED, UNSUPPORTED, INTERNAL). Append-only.
    • message ErrorInfo { ErrorReason reason=1; map<string,string> metadata=2; string message=3; }.
    • Descriptor golden regenerated; contract_stability::descriptor_matches_committed_golden passes.
  • Helper (nuncio_proto::errors) — status()/status_with_metadata() build a tonic::Status carrying a prost-encoded ErrorInfo in the grpc-status-details-bin trailer (via Status::with_details), with the canonical code from ErrorReason::code(). error_info()/error_reason() decode it client-side (from Status::details()).
  • Reason → canonical code: VALIDATION_FAILED→InvalidArgument, *_NOT_FOUND→NotFound, ALREADY_EXISTS→AlreadyExists, NOT_CONFIGURED→FailedPrecondition, BACKEND_UNREACHABLE→Unavailable, AUTH_REQUIRED→Unauthenticated, UNSUPPORTED→Unimplemented, INTERNAL/UNSPECIFIED→Internal.
  • Representative site conversions in `nunciod/src/grpc.rs**: account not-found (RemoveAccount, TestAccountConnection), message not-found (GetMessage, MarkRead), event not-found (GetEvent), contact not-found (GetContact), rule not-found (UpdateRule), NSQL validation (CreateRule/UpdateRule/PreviewRule + account validate), calendar not-configured (Calendar.Sync). Canonical codes are unchanged, so existing code()/message() assertions still hold. The exhaustive ~90-site sweep is left to WS-A7.
  • E2E test crates/nunciod/tests/typed_errors_e2e_test.rs — boots the real daemon server and, over authenticated gRPC, decodes the reason + metadata from returned Status details for account-not-found, message-not-found, not-configured, and validation-failed.

Gate (3 commands, no cargo verify)

  • cargo fmt --all -- --check — clean
  • cargo check-all (clippy --all-targets --workspace -D warnings) — clean
  • cargo test-all — all pass, 0 ignored, 0 failed (incl. nuncio-proto 27 tests with contract_stability + errors::tests, and the new typed_errors_e2e_test)

Notes / follow-ups

  • ALREADY_EXISTS is defined in the taxonomy but not yet wired to a call site (no natural already-exists path today — save_account upserts); reserved for WS-A7.
  • Contacts sync's "no CardDAV configured" gap still returns Internal (unchanged) rather than NOT_CONFIGURED; left for the WS-A7 correctness pass to avoid scope creep.

🤖 Generated with Claude Code

Closes #273 (WS-A1) — the first story in the serial proto queue. Establishes the typed-error pattern for the whole `nuncio.v1` contract + CLI. ## What this adds - **Proto (`nuncio.v1`)** — a frozen, machine-readable error taxonomy: - `enum ErrorReason` (`ERROR_REASON_UNSPECIFIED=0`, plus `VALIDATION_FAILED`, `ACCOUNT/RULE/MESSAGE/EVENT/CONTACT_NOT_FOUND`, `ALREADY_EXISTS`, `NOT_CONFIGURED`, `BACKEND_UNREACHABLE`, `AUTH_REQUIRED`, `UNSUPPORTED`, `INTERNAL`). Append-only. - `message ErrorInfo { ErrorReason reason=1; map<string,string> metadata=2; string message=3; }`. - Descriptor golden regenerated; `contract_stability::descriptor_matches_committed_golden` passes. - **Helper (`nuncio_proto::errors`)** — `status()`/`status_with_metadata()` build a `tonic::Status` carrying a prost-encoded `ErrorInfo` in the `grpc-status-details-bin` trailer (via `Status::with_details`), with the canonical code from `ErrorReason::code()`. `error_info()`/`error_reason()` decode it client-side (from `Status::details()`). - **Reason → canonical code**: `VALIDATION_FAILED→InvalidArgument`, `*_NOT_FOUND→NotFound`, `ALREADY_EXISTS→AlreadyExists`, `NOT_CONFIGURED→FailedPrecondition`, `BACKEND_UNREACHABLE→Unavailable`, `AUTH_REQUIRED→Unauthenticated`, `UNSUPPORTED→Unimplemented`, `INTERNAL`/`UNSPECIFIED→Internal`. - **Representative site conversions** in `nunciod/src/grpc.rs**: account not-found (RemoveAccount, TestAccountConnection), message not-found (GetMessage, MarkRead), event not-found (GetEvent), contact not-found (GetContact), rule not-found (UpdateRule), NSQL validation (CreateRule/UpdateRule/PreviewRule + account validate), calendar not-configured (Calendar.Sync). Canonical codes are unchanged, so existing code()/message() assertions still hold. The exhaustive ~90-site sweep is left to WS-A7. - **E2E test** `crates/nunciod/tests/typed_errors_e2e_test.rs` — boots the real daemon server and, over authenticated gRPC, decodes the reason + metadata from returned `Status` details for account-not-found, message-not-found, not-configured, and validation-failed. ## Gate (3 commands, no `cargo verify`) - `cargo fmt --all -- --check` — clean - `cargo check-all` (clippy `--all-targets --workspace -D warnings`) — clean - `cargo test-all` — all pass, **0 ignored, 0 failed** (incl. `nuncio-proto` 27 tests with `contract_stability` + `errors::tests`, and the new `typed_errors_e2e_test`) ## Notes / follow-ups - `ALREADY_EXISTS` is defined in the taxonomy but not yet wired to a call site (no natural already-exists path today — `save_account` upserts); reserved for WS-A7. - Contacts sync's "no CardDAV configured" gap still returns `Internal` (unchanged) rather than `NOT_CONFIGURED`; left for the WS-A7 correctness pass to avoid scope creep. 🤖 Generated with [Claude Code](https://claude.com/claude-code)
Sign in to join this conversation.