# lark-cli Error Contract `errs/` defines a typed, RFC 7807–aligned error taxonomy for the CLI. Three audiences depend on it: **AI agents and shell scripts** parsing the JSON envelope on stderr; **protocol adapters** mapping CLI errors into MCP / OAuth shapes; and **framework + business code** producing errors. This file is the single source of truth for all three. Something off in production? See **Troubleshooting**. ## Invariants 1. Every error belongs to exactly one **Category**. The set is closed (`errs/category.go`); adding a member requires deliberate review. 2. Every typed error has a **Subtype** — a stable lowercase-with-underscores identifier declared in `errs/subtypes*.go`. Undeclared subtypes fail CI. Every error path constructs a typed `*errs.*` error at its origin, so the constraint applies uniformly. 3. **`Category` + `Subtype`** are wire-stable identifiers consumers may branch on. Renaming either is a breaking change. 4. `Code` is the upstream numeric code when known (e.g. Lark API code). It is `omitempty` and never carries CLI-internal meaning. 5. Every typed error embeds `errs.Problem`. `CheckProblemEmbed` rejects exported `*Error` structs that do not. 6. Wrapping is idempotent: re-wrapping an already-typed error returns it unchanged across the `errors.As` / `errors.Unwrap` chain. 7. For the typed-envelope path, exit codes derive from `Category` only via `output.ExitCodeForCategory` — including `SecurityPolicyError`, which exits `6` via `CategoryPolicy`. `output.ErrBare(code)` is the exception: it constructs an `*output.BareError`, a deliberate silent-exit signal (stdout already carries the answer) that bypasses the envelope (see **Predicate commands** below). ## Wire format Typed errors render to **stderr** as one JSON object per process exit: ```json { "ok": false, "identity": "user", "error": { "type": "authorization", "subtype": "missing_scope", "code": 99991679, "message": "missing scope `calendar:event:create` for app cli_xxx", "hint": "run `lark-cli auth login --scope \"calendar:event:create\" --no-wait --json` to get device_code and verification_url; present verification_url to the user exactly and end this turn; after the user confirms authorization, run `lark-cli auth login --device-code ` in a later turn to finish login", "log_id": "20260520-0a1b2c3d", "missing_scopes": ["calendar:event:create"] } } ``` | Field | Stability | Notes | |-------|-----------|-------| | `ok` | wire-stable | always `false` for errors | | `identity` | wire-stable | `user` \| `bot` — caller identity; omitted when not resolved | | `error.type` | **wire-stable** | one of the 9 Categories | | `error.subtype` | **wire-stable** | declared Subtype constant | | `error.code` | wire-stable | upstream numeric code, omitted when zero | | `error.message` | informational | not safe to branch on | | `error.hint` | informational | actionable recovery guidance | | `error.log_id` | informational | upstream request id (server-side trace) | | `error.retryable` | wire-stable | `true` when present; omitted when `false` | | `error.retry_after_seconds` | per-Subtype-stable | upstream-provided minimum delay before retry; emitted when available for retryable `api/rate_limit` and HTTP-backed `network` errors | | `error.param` | per-Subtype-stable | single offending parameter (`ValidationError`); see **Validation parameters** | | `error.params` | per-Subtype-stable | per-parameter validation detail array (`ValidationError`); see **Validation parameters** | | per-Subtype extension fields | per-Subtype-stable | e.g. `missing_scopes`, `console_url`, `challenge_url`; `console_url` is emitted for developer/admin recovery such as `app_scope_not_applied`, not user `missing_scope` | For retryable `type=api, subtype=rate_limit`, and for a retryable HTTP-backed `network` error, the CLI may emit `retry_after_seconds` when the upstream response supplies a precise delay. Replay-safe Lark OpenAPI streaming requests prefer the gateway's `x-ogw-ratelimit-reset` remaining-seconds header, then standard `Retry-After`. Other streaming requests retain their existing terminal error behavior. If neither header contains a valid delay, the field is omitted and the hint recommends exponential backoff with jitter. The envelope intentionally does not expose an implementation detail such as `retry_after_source`. Generic command dispatch does not replay a request automatically; a bounded operation such as multipart download may consume this field under its own idempotency and retry budget. `SecurityPolicyError` renders through the same typed envelope as every other category. `error.type` is `"policy"`, `error.subtype` is one of `challenge_required` / `access_denied`, and process exit is `6` via `CategoryPolicy`. ### Success envelope (stdout) For contrast: success responses render to **stdout** as an `output.Envelope` (`internal/output/envelope.go`), exit code `0`: ```json { "ok": true, "identity": "user", "data": { "guid": "e297d3d0-..." }, "meta": { "count": 1 } } ``` Consumers must branch on `ok` (or the process exit code). The success envelope has **no top-level `code` or `msg` field** — `code` exists only inside `error`, where it is the upstream numeric code (invariant 4). Wrappers that follow the raw OpenAPI convention and test `code == 0` will misclassify every successful call as a failure, which is especially dangerous around write commands (e.g. retrying a create that already succeeded). ## Categories | Category | When | Exit | Typed struct | |----------|------|------|--------------| | `validation` | malformed user input | 2 | `ValidationError` | | `authentication` | no valid token / login required | 3 | `AuthenticationError` | | `authorization` | token lacks scope / app permission denied | 3 | `PermissionError` | | `config` | local config missing / unbound | 3 | `ConfigError` | | `network` | DNS, refused, timeout, transport | 4 | `NetworkError` | | `api` | server-side Lark error w/o specific bucket | 1 | `APIError` | | `policy` | content safety / security challenge | 6 | `SecurityPolicyError`, `ContentSafetyError` | | `internal` | SDK contract violation / decode failure | 5 | `InternalError` | | `confirmation` | high-risk action needs `--yes` | 10 | `ConfirmationRequiredError` | Canonical mapping: `internal/output/exitcode.go` `ExitCodeForCategory`. > **Note on the `authorization` / `PermissionError` asymmetry.** The wire > `type` field uses the RFC 7807 / taxonomy-formal name `"authorization"`, > but the Go type is named `PermissionError`. This is deliberate, following > the gRPC / Google APIs convention (`codes.Unauthenticated` + > `codes.PermissionDenied`): each name is chosen to be **maximally > distinct and readable on its own**, not to be perfectly symmetric. > `AuthenticationError` and `AuthorizationError` differ visually only at > the 5th character and are easy to confuse in code review; > `AuthenticationError` and `PermissionError` cannot be confused. The wire > field stays formal because it is the protocol-level taxonomy; the Go > type favors call-site readability. ## Flow ``` call site │ constructs typed error (e.g. *errs.ValidationError) ▼ command runE returns err │ ▼ cmd/root.go handleRootError dispatches: ├─ typed (errs.ProblemOf) → typed JSON envelope; exit = ExitCodeOf(err) │ (includes *errs.SecurityPolicyError → policy envelope, exit 6; │ *errs.ConfigError, constructed typed at origin) ├─ *output.PartialFailureError → no stderr envelope (ok:false result already on stdout); exit = code ├─ *output.BareError → no envelope (stdout already written); exit = code └─ Cobra usage error → typed validation envelope (invalid_argument); exit 2 ``` The dispatcher emits a JSON envelope on stderr for both the typed branch and residual Cobra usage errors (missing required flag, unknown command, argument validation): the latter are classified into a typed validation envelope (`invalid_argument`) and exit `2`, matching the explicit flag and subcommand guards. ### Concealed commands (`validation/command_unavailable`) `command_unavailable` is emitted only by a distribution that explicitly opts into presenting plugin-restricted commands as absent. Direct invocation and `help ` both produce a typed validation envelope and exit `2`: ```json { "ok": false, "error": { "type": "validation", "subtype": "command_unavailable", "message": "requested capability is not available in this CLI distribution" } } ``` For consumers, this subtype means the capability is not part of the current binary's usable command surface. Do not treat it as an authentication failure, attempt to bypass local policy, or infer that installing credentials will make the command available. The distribution may customize `message`; branch only on `type` and `subtype`. The concealed wire shape deliberately omits `param`, `policy_source`, `rule_name`, and `reason_code`, so it does not disclose the plugin policy that removed the capability. An in-process Go caller may still observe the original denial through the error cause for auditing. This opt-in behavior does not change the other command-resolution contracts: - an ordinary unknown command remains `validation/invalid_argument`; - a restricted command in the legacy visible presentation remains `validation/failed_precondition` with its policy diagnostics; and - the default CLI build does not emit `command_unavailable`. ### Predicate commands (`output.BareError`) A small class of commands is **predicates**: they answer a yes/no question and signal the answer through the shell exit code so callers can write `if cmd; then ... fi`. `lark-cli auth check` is the canonical example — its `README` contract is `exit 0 = ok, 1 = missing`. These commands deliberately: 1. write a structured JSON answer to **stdout** themselves, and 2. return `output.ErrBare(exitCode)` — an `*output.BareError` — to communicate the exit code to the dispatcher without producing a `stderr` envelope. `*output.BareError` is **not** an error in the typed-envelope sense — it carries no category, subtype, or message, only an exit code. It is a one-bit output-control signal that lives outside the contract for the same reason `grep -q` / `diff` / `systemctl is-active` set non-zero exit codes without printing anything to stderr: pollution of stderr by a predicate's negative answer would break `2>/dev/null` log hygiene in caller scripts. A second class also uses `ErrBare`: a command that emits its own complete structured result envelope on **stdout** under `--json` (e.g. `update`, whose `{ok:false, error:{type, message}}` is its established output shape) and needs only the exit code conveyed, with no `stderr` envelope. Like a predicate, its answer is already on stdout; `ErrBare` carries the exit code alone. New code should not reach for `ErrBare` unless the command's full answer is already on stdout — a predicate's yes/no, or a self-contained result envelope as above. Anything whose error content must reach the caller on `stderr` belongs in a typed `*errs.XxxError` — or, for a batch result, in the partial-failure outcome below. ### Partial failure (batch / multi-status) A batch command (e.g. `drive +push` / `+pull` / `+sync`) that processes many items can finish in a third state, neither full success nor a single error: some items succeeded and some failed. Its primary output is the per-item result, so it does **not** belong in a `stderr` error envelope. Such a command returns `runtime.OutPartialFailure(data, meta)`, which: 1. writes the full result to **stdout** as an `ok:false` envelope — the summary and every per-item outcome (succeeded *and* failed) stay machine-readable, exactly as a successful `Out(...)` would carry them, but with `ok` honestly reporting failure; and 2. returns `*output.PartialFailureError`, a typed exit signal the dispatcher maps to a non-zero exit code while writing nothing further to `stderr`. This is distinct from `ErrBare` (a predicate's one-bit answer) and from a typed `*errs.XxxError` (a `stderr` error envelope): a partial failure is a *result*, reported on stdout, that also failed. Consumers branch on `ok == false` and then read `data.summary` / `data.items[]`. ## Consumers ### Go (in-process) ```go var pe *errs.PermissionError if errors.As(err, &pe) { fmt.Println("missing:", pe.MissingScopes) } ``` Predicates cover the common categories (`errs/predicates.go`): ```go if errs.IsAuthentication(err) { ... } if errs.IsPermission(err) { ... } if errs.IsValidation(err) { ... } ``` Type-agnostic field access: ```go if p, ok := errs.ProblemOf(err); ok { log.Printf("cat=%s subtype=%s retryable=%t", p.Category, p.Subtype, p.Retryable) } exitCode := output.ExitCodeOf(err) // ExitInternal for non-typed errors ``` ### Shell / AI ```bash out=$(lark-cli ... 2>&1) code=$? # Defensive guard: tolerate any non-JSON output before parsing with jq. if ! jq -e . >/dev/null 2>&1 <<<"$out"; then printf '%s\n' "$out" >&2 exit "$code" fi case "$(jq -r '.error.type // empty' <<<"$out")" in authorization) jq -r '.error.missing_scopes[]' <<<"$out" ;; network) echo "transport failure, safe to retry" ;; internal) echo "bug — file an issue with log_id $(jq -r '.error.log_id // "n/a"' <<<"$out")" ;; esac ``` Unknown fields are forward-compatible additions: ignore, don't fail. Branch only on `type`, `subtype`, `code`, `retryable`, `retry_after_seconds`, and declared extension fields — `message` is human-readable prose that may be reworded without notice. ## Producers ### Quick reference The canonical producer surface is the **builder API in `errs/types.go`** (per type: struct + `NewXxxError` + chained `WithX` setters live in one place): each `NewXxxError(subtype, format, args...)` locks `Category` at the constructor name, requires `Subtype` + `Message` positionally, and exposes optional fields via chained `.WithX(...)` setters. Struct literals remain legal for framework dynamic paths (e.g. classifier fanout) but the lint `CheckTypedErrorCompleteness` still requires `Category` + `Subtype` + `Message` on any literal it sees. | Situation | Use | |-----------|-----| | Bad user input | `errs.NewValidationError(subtype, msg).WithParam("--flag")` | | Login required | `errs.NewAuthenticationError(errs.SubtypeTokenMissing, msg)` | | Token lacks scope | `errclass.BuildAPIError(resp, ctx)` | | Local config missing | `errs.NewConfigError(errs.SubtypeNotConfigured, msg)` | | Transport failure | `errs.NewNetworkError(errs.SubtypeNetworkTimeout, msg).WithCause(err)` (subtype: `timeout` / `tls` / `dns` / `server_error` / `transport` / `protocol`) | | Peer answered but broke the protocol | `errs.NewNetworkError(errs.SubtypeNetworkProtocol, msg)` — e.g. a `206` whose `Content-Range` is not the range the transfer resumed from. Never retryable: replaying the same request cannot change the answer. | | Resource changed mid-transfer | `errs.NewNetworkError(errs.SubtypeNetworkRepresentationChanged, msg).WithRetryable().WithHint(...)` — the peer behaved correctly, so starting the transfer over reads the current version. | | Lark API error | `errclass.BuildAPIError(resp, ctx)` | | SDK / decode bug | `errs.NewInternalError(errs.SubtypeSDKError, msg).WithCause(err)` | | Policy block | `errs.NewSecurityPolicyError(subtype, msg).WithChallengeURL(url)` or `errs.NewContentSafetyError(subtype, msg).WithRules(...)` | | Needs `--yes` | `errs.NewConfirmationRequiredError(risk, action, msg)` | ### Authoring discipline Five rules every producer follows. Some are enforced by `lint/errscontract` AST guards (`go run -C lint . ..`); the rest by code review. #### Propagate typed errors unchanged A function that receives an error already carrying `errs.Problem` returns it as-is up the stack. Reclassification at non-boundary frames (e.g., wrapping a `*ValidationError` into `*InternalError`) defeats the single-source taxonomy and silently downgrades typed signals. Conforming: ```go _, err := runtime.DoAPI(req, opts) if err != nil { return err // already typed by the framework boundary } ``` Non-conforming: ```go return fmt.Errorf("calling /open-apis: %v", err) // %v strips the typed shape return &errs.InternalError{Cause: err} // re-decides category ``` #### Never return a typed-nil pointer A typed-nil pointer (`var pe *errs.PermissionError; return pe`) wraps as a non-nil interface — `errors.As` matches and `.Error()` may panic. Return interface `nil` literally. Non-conforming: ```go var e *errs.ValidationError // nil pointer return e // non-nil interface holding nil pointer ``` #### Let `Category` derive the exit code Do not pick exit codes by hand in new typed producers — `ExitCodeForCategory` maps `Category` to the shell code. A new exit-code requirement means a new `Category`, not a one-off override at the call site. (The only exits not derived from `Category` are the `*output.BareError` and the `*output.PartialFailureError` signals, which carry their own code by design and sit outside the typed-envelope contract — see **Predicate commands**.) #### Split `Message`, `Hint`, and `Cause` Each field carries a distinct role: | Field | Carries | Style | |-------|---------|-------| | `Message` | What is wrong | Direct, lowercase first letter, no trailing period | | `Hint` | What to do next | Imperative ("run `lark-cli auth login`", "use `--as user`") | | `Cause` | The wrapped upstream `error`, not a stringified copy | Typed; serialized as `json:"-"` | `Hint` must not be merged into `Message`. AI agents and humans read them on separate channels; merging defeats both. `Cause` must be a real `error`. If the upstream returned an `error`, place it in `Cause` so `errors.Is` and `errors.Unwrap` walk the chain — do not inline its `.Error()` into `Message`. Conforming: ```go return errs.NewNetworkError(errs.SubtypeNetworkTransport, "request to /open-apis failed after 3 retries"). WithHint("check connectivity and retry; set --log-level debug if it persists"). WithCause(ioErr) ``` Non-conforming: ```go Message: fmt.Sprintf("request failed: %v — retry later", ioErr) // conflates what + what-to-do + cause into one string ``` #### Validation parameters: `Param` and `Params` `ValidationError` carries two additive parameter fields. Both are optional; a producer sets whichever fits the failure. **`Param string` (wire `param`)** — the single offending parameter. When a `*ValidationError` originates from a flag value, `Param` holds the flag name with leading dashes (`"--priority"`, not `"priority"`). AI agents grep this field literally to surface "the bad flag was `--X`". For positional arguments, use the canonical name without dashes (`"target_user_id"`). **`Params []InvalidParam` (wire `params`)** — per-parameter validation detail, for failures that need to report *which* parameters failed and *why*, one entry each. Each `errs.InvalidParam` is `{Name, Reason string, Suggestions []string}`: `Name` identifies the parameter, `Reason` states why it failed, and the optional `Suggestions` (wire `suggestions`, omitted when empty) carries ranked candidate corrections an agent can retry with — the did-you-mean candidates for an unknown flag or subcommand — without parsing the human-facing `hint`. This is the CLI's rendering of the RFC 7807 `invalid-params` extension member (RFC 7807 §3.1). The wire key is `params`, not `invalid_params`: the enclosing envelope already carries `type:"validation"`, so the `invalid_` qualifier would be redundant on the wire. `Param` and `Params` are independent additive fields, not alternates of a single representation. Use `Param` for the common single-parameter error; use `Params` when one failure spans several parameters or needs a per-parameter reason. Set with `.WithParam("--flag")` / `.WithParams(...)`. A `params` wire example (multiple parameters each carrying a reason): ```json { "ok": false, "identity": "user", "error": { "type": "validation", "subtype": "invalid_argument", "message": "2 parameters failed validation", "params": [ { "name": "--start", "reason": "expected RFC3339, got \"yesterday\"" }, { "name": "--end", "reason": "must be after --start" } ] } } ``` ### Constructing typed errors Prefer the **builder API**. The constructor pins `Category` + `Subtype` + `Message`, the chained setters fill optional fields, and the resulting value retains its concrete `*XxxError` pointer through the chain so type-specific setters remain reachable to the end: ```go return errs.NewValidationError(errs.SubtypeInvalidArgument, "--data must be a valid JSON object: %v", parseErr). WithParam("--data") ``` Why builder over struct literal: - `Category` is locked at the function name — caller cannot mis-specify it - `Subtype` and `Message` are positional arguments — `go build` rejects the call site if either is missing - The chain reads top-down: required identity first, optional fields after - Message is `fmt.Sprintf`-formatted from `(format, args...)`, matching `fmt.Errorf` muscle memory and avoiding a separate `Sprintf` line Struct literals remain legal — `CheckTypedErrorCompleteness` continues to enforce `Category` + `Subtype` + `Message` on any literal it sees — and the framework classifier (`internal/errclass/classify.go`) still uses them on the dynamic dispatch path where a `Problem` value is composed once and wrapped per Category branch. Outside that pattern, new code should reach for the builder. When the validation logic outgrows a single range check — multiple flags, format parsing, conditional rules — extract it into a helper that also returns the typed `*errs.ValidationError`; the helper, not `Execute`, sets `Param` (a helper bound to one shortcut is normal in this codebase; see `parseTimeRange` in `shortcuts/calendar/calendar_agenda.go`). ### Wrapping upstream errors When a producer receives an error from a function it called, four cases cover the decision: | Source | Decision | Example | |--------|----------|---------| | Helper returned a typed `*errs.*Error` | Return unchanged | `return err` | | Helper returned an untyped error tied to user input (`strconv.Atoi`, `json.Unmarshal`, …) | Construct a typed error; put the untyped error in `Cause` | `return errs.NewValidationError(errs.SubtypeInvalidArgument, "invalid --data: %v", jsonErr).WithCause(jsonErr)` | | SDK call via `runtime.DoAPI` failed | Return unchanged — the framework boundary already wrapped it | `return err` | | Invariant broken (must-not-happen state) | Lift with `errs.WrapInternal`, set a `Message` describing the invariant | `return errs.WrapInternal(fmt.Errorf("identity resolver returned nil: %w", err))` | Prefer the `Cause` field over `fmt.Errorf("ctx: %w", err)` when attaching an upstream error to a typed one. `Cause` is the chain `errs.UnwrapTypedError` walks and the chain consumer code expects; `fmt.Errorf("...: %w", err)` only affects `.Error()` output, which the wire envelope does not surface. #### Boundary helpers (framework-internal) These helpers are called from framework boundaries, not from domain code: - `errs.WrapInternal(err)` — lifts an untyped error to `*InternalError`; already-typed errors pass through unchanged. - `client.WrapDoAPIError(err)` — classifies SDK transport / decode failures into `*errs.NetworkError` / `*errs.InternalError` at the SDK boundary. - `client.WrapJSONResponseParseError(body, err)` — lifts response-layer JSON parse failures to `*errs.InternalError`. If you find yourself reaching for `WrapDoAPIError` from a `shortcuts/**` package, you are probably calling the SDK at the wrong layer — go through `runtime.DoAPI`. ### Extending the taxonomy #### Add a Subtype 1. Add a constant in `errs/subtypes.go` under the right Category block. Subtypes are framework-shared — service-specific Subtypes are an anti-pattern (the wire `code` field already identifies the source service; Subtype encodes cross-service semantics like `not_found`, `quota_exceeded`). 2. If it maps from a Lark code, register the mapping in `internal/errclass/codemeta_.go`. 3. Add a dispatch test in `internal/errclass/classify_test.go`. 4. Reference the constant from a producer. 5. `go run -C lint . ..` — `CheckDeclaredSubtype` fails until the constant is wired through. `ad_hoc_*` subtypes are a temporary unblocker that label a value for follow-up, not a permanent identifier. Resolve any `ad_hoc_*` to a declared constant within one week of introduction; `CheckAdHocSubtype` emits a warning to keep them visible. #### Add a typed Error struct Rare; the existing structs cover the 9 Categories with room. If you must: 1. In `errs/types.go`, add a new section with: the struct embedding `errs.Problem`, a nil-receiver-safe `Unwrap()` if it carries `Cause`, a `NewXxxError(subtype, format, args...)` constructor, and one chained `WithX` setter per extension field. 2. Add an `IsXxx` predicate in `errs/predicates.go`. 3. Add a wire-format pin in `errs/marshal_test.go` and a builder-chain pin in `errs/types_test.go`. 4. Add the concrete type and deep-copy handling to `internal/recovery.CloneTyped`, then extend `TestRenderClonesEveryConcreteTypedErrorAndPreservesWireExtensions`. `CheckProblemEmbed` enforces the `Problem` embed at lint time. New top-level wire fields are forbidden — per-Subtype data goes into the typed struct as a documented extension field, not into the envelope's top level. ## CI guards Two golangci-lint rules and the custom `errscontract` AST module enforce the contract; CI runs all three on every PR. **golangci-lint** — scopes are defined in `.golangci.yml` (not duplicated here, so this spec cannot drift from the lint config): | Rule | Enforces | |------|----------| | forbidigo `errs-no-bare-wrap` | a command / wire-boundary final error must be typed (`errs.NewXxxError`), never a bare `fmt.Errorf` / `errors.New`; a genuine intermediate wrap opts out with `//nolint:forbidigo` + a reason | | errorlint | every error wrap uses `%w` and every comparison uses `errors.Is` / `errors.As` — interior wraps stay legal but cannot break the `errors.Unwrap` chain the typed boundary relies on | **errscontract** (`lint/errscontract/`, a separate Go module so its `golang.org/x/tools` dependency stays out of the shipped binary; run locally with `go run -C lint . ..`): | Check | Enforces | |-------|----------| | `CheckNoLegacyEnvelopeLiteral` / `CheckNoLegacyCommonHelperCall` / `CheckNoLegacyRuntimeAPICall` | the removed `output.*` legacy error surface cannot be reintroduced anywhere | | `CheckProblemEmbed` | every exported `*Error` embeds `errs.Problem` | | `CheckDeclaredSubtype` | every `Subtype:` value is a declared constant (or `ad_hoc_*`) | | `CheckTypedErrorCompleteness` | every typed-error struct literal sets `Category`, `Subtype`, and `Message` | | `CheckAdHocSubtype` | `ad_hoc_*` Subtypes flagged for promotion (warning) | | `CheckNoRegistrar` | no `mergeCodeMeta` / `RegisterServiceMap` from service code | `errscontract` also carries framework-internal invariants (nil-safe `Unwrap`, builder immutability, unwrap symmetry); see `lint/errscontract/` for the full set and `lint/README.md` for adding a new lint domain. ## Stability | Tier | Surface | Change policy | |------|---------|---------------| | Wire-stable | `error.type`, `error.subtype`, `error.code`, `error.retryable`, declared extension fields, `Category` enum values | breaking change ⇒ semver major; deprecation window required | | Additive | new Category, new declared Subtype, new extension field on an existing struct | minor release; consumers ignore unknown fields by contract | | Experimental | `ad_hoc_*` Subtypes; fields documented as such in `errs/types.go` | may change or be promoted/removed within one release | ## Troubleshooting **Envelope shows `type=api subtype=unknown` for what should be a more specific category.** The Lark code is unknown to `LookupCodeMeta` and fell through to the generic bucket (`internal/errclass/classify.go`). Add the code to `internal/errclass/codemeta_.go` with the right Category and Subtype, plus a dispatch test in `internal/errclass/classify_test.go`. **Envelope shows `type=internal subtype=sdk_error`.** Origin is `client.WrapDoAPIError` taking the non-transport branch (`internal/client/api_errors.go`). Check: did the SDK fail to decode the response (look for `subtype=invalid_response` in the wrapped chain)? Was the transport detection too narrow for this error (e.g. a `*url.Error` with an inner that does not satisfy `net.Error`)? Either widen the transport predicate or add an explicit typed wrap upstream. **`CheckDeclaredSubtype` rejects my Subtype.** The constant must be declared in `errs/subtypes*.go` *and* referenced from the dispatch path. Bare string literals trip `CheckDeclaredSubtype` unless they match the `ad_hoc_*` prefix; `ad_hoc_*` then trips `CheckAdHocSubtype` as a follow-up warning. **`errors.As(&typedErr)` panics with a nil-pointer receiver.** A typed-nil slipped through. All typed errors define nil-safe `Unwrap()`, but returning a typed-nil pointer up the stack still defeats `errors.As`. Return interface `nil` from constructors, never a typed-nil pointer. **Exit code is 5 (internal) when I expected 3 (auth).** The error was not typed before reaching `handleRootError`. Wrap at the boundary (`client.WrapDoAPIError` or a typed constructor) — the bare `error.Error()` string cannot be classified retroactively. ## Security & privacy - `log_id` is a server-side trace token. Safe to surface; it does not carry user content. - `missing_scopes` is app configuration, not user data. - `Message` and `Hint` must not contain tokens, JWTs, or personally identifying values. CI does not catch this — producer responsibility. - Wrapped `Cause` is **not** serialized to the wire (`json:"-"`). It is retained for in-process `errors.Is` / `errors.Unwrap` traversal and optional debug logging only. ## Pointers (task-driven) - *Which struct to construct?* → **Producers / Quick reference** - *Add a new condition?* → **Add a Subtype** - *Consume from a shell script?* → **Consumers / Shell / AI** - *Understand or fix a CI failure?* → **CI guards** - *Read source.* → `errs/doc.go` → `errs/category.go` → `errs/types.go` → `errs/predicates.go` → `internal/errclass/` → `cmd/root.go` `handleRootError`.