# 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. This document describes the **typed authoring target**. The refactor lands in stages; some boundaries (e.g. `client.WrapDoAPIError`) still operate on legacy shapes today — see **Migration** for what is live in each stage. Migrating an `*output.ExitError` call site? See **Migration**. 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 **newly constructed** typed error has a **Subtype** — a stable lowercase-with-underscores identifier declared in `errs/subtypes*.go`. Undeclared subtypes fail CI. The constraint applies only to typed `*errs.*` literals; stage-1 legacy `*core.ConfigError` flows via the dispatcher's `asExitError` → legacy envelope path (not the typed taxonomy) and is unaffected. `errcompat.PromoteConfigError` is a stage-1 passthrough; its stage-2+ typed migration will subject the promoted typed error to this Subtype constraint at that time. 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`. Unmigrated `*output.ExitError` producers still carry a hand-set `Code` until they finish migrating. `output.ErrBare(code)` is the lone exception: a deliberate predicate-command signal 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", "log_id": "20260520-0a1b2c3d", "missing_scopes": ["calendar:event:create"], "console_url": "https://open.feishu.cn/app/cli_xxx/auth?q=..." } } ``` | 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` | | per-Subtype extension fields | per-Subtype-stable | e.g. `missing_scopes`, `console_url`, `challenge_url` | `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`. The legacy `auth_error` envelope at exit `1` has been retired. ## 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: ├─ output.ErrBare(code) → no envelope (stdout already written); exit = code ├─ typed (errs.ProblemOf) → typed JSON envelope; exit = ExitCodeOf(err) │ (includes *errs.SecurityPolicyError → policy envelope, exit 6) ├─ *core.ConfigError → promoted to typed via errcompat ↑ ├─ *output.ExitError → legacy JSON envelope; exit = exitErr.Code └─ untyped / Cobra error → plain "Error: " (no envelope); exit 1 ``` Only the typed and `*output.ExitError` branches emit a JSON envelope on stderr. Untyped errors (including Cobra's "required flag missing" / unknown subcommand messages) print plain text and exit `1` — consumers must tolerate that fallback. ### Predicate commands (`output.ErrBare`) 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)` to communicate the exit code to the dispatcher without producing a `stderr` envelope. `output.ErrBare` is **not** an error in the typed-envelope sense — it carries no category, subtype, or message. 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. New code should not reach for `ErrBare` unless the command is genuinely a predicate. Anything carrying recoverable error content belongs in a typed `*errs.XxxError`. ## 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=$? # Untyped / Cobra errors print plain text — guard before 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`, 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`) | | 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. (Legacy `*output.ExitError` retains hand-set codes until removal; `SecurityPolicyError` retains a hand-set code on main until the framework migration PR retires the carve-out — see **Migration**.) #### 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 ``` #### `ValidationError.Param` uses the `--flag` form 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"`). ### 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. Legacy helpers (`output.ErrValidation`, `output.ErrAuth`, `output.ErrNetwork`) remain callable during migration but are `// Deprecated:` — new code goes through the builder. #### Shortcut `Execute` walkthrough Adapted from `shortcuts/calendar/calendar_suggestion.go:222`, whose legacy form is `output.ErrValidation("--duration-minutes must be between 1 and 1440")`. The typed migration target (builder form): ```go Execute: func(ctx context.Context, runtime *common.RuntimeContext) error { duration := runtime.Int("duration-minutes") if duration < 1 || duration > 1440 { return errs.NewValidationError(errs.SubtypeInvalidArgument, "--duration-minutes must be between 1 and 1440, got %d", duration). WithHint("pass a value in [1, 1440]"). WithParam("--duration-minutes") } _, err := runtime.DoAPI(req, opts) if err != nil { return err // already typed by the framework boundary; propagate } return nil } ``` Two patterns visible: a producer site (the typed `*errs.ValidationError` above) and a propagation site (the `return err` after `runtime.DoAPI`, applying [Propagate typed errors unchanged](#propagate-typed-errors-unchanged)). 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:144`). ### 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_builder_test.go`. `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 | Check | Enforces | Where | |-------|----------|-------| | forbidigo | business path (`shortcuts/**`, `cmd/service/**`) must not call legacy `output.*` error constructors — route through the typed classifier | `.golangci.yml` | | `CheckProblemEmbed` | every exported `*Error` embeds `errs.Problem` | `lint/errscontract/` AST | | `CheckNoRegistrar` | no `mergeCodeMeta` / `RegisterServiceMap` from service code | `lint/errscontract/` AST | | `CheckAdHocSubtype` | `ad_hoc_*` Subtypes labeled for promotion (warn) | `lint/errscontract/` AST | | `CheckDeclaredSubtype` | every `Subtype:` value is a declared constant or `ad_hoc_*` | `lint/errscontract/` AST | | `CheckTypedErrorCompleteness` | every `*errs.Error{Problem: errs.Problem{...}}` literal must set `Category`, `Subtype`, and `Message` | `lint/errscontract/` AST | CI runs `lint/` on every PR. Locally: `go run -C lint . ..`. The lintcheck CLI lives in its own Go module so its `golang.org/x/tools` dependency stays out of the shipped `lark-cli` binary's module graph; see `lint/README.md` for how to add 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 | The deprecated `*output.ExitError` surface is outside these tiers — it will be removed once business migration completes. ## Migration **Strategy shift (2026-05-26).** The original plan (`docs/design/errors-refactor/spec.md` v2.12 §9) was a centrally-driven 4-PR rollout — framework → auth domain → multi-pilot → full-repo + legacy removal. That plan is **superseded** by a hybrid model: framework owner ships framework-level hardening (including a typed `*errs.*Error` migration of `internal/**`) as one focused PR; business-domain typed migration is **self-service** via [`docs/errors-guide.md`](../docs/errors-guide.md) and the builder API, with no central sweep timeline. Why the shift: 800+ legacy call sites split across 8+ business domains do not all share a single reviewer's bandwidth, and the contract is now expressive enough that each domain owner can migrate their own code from the guide without coordinating with framework owner. ### Current state 1. **Framework slice — ✅ shipped (PR #984).** The `errs/` typed taxonomy, classifier (`internal/errclass`), promotion stub (`internal/errcompat`, passthrough), dispatcher hook (`WriteTypedErrorEnvelope`), and the `lint/errscontract` AST guards. Wire shapes preserved byte-for-byte versus pre-PR, with **one intentional semantic fix**: config-class errors (`*core.ConfigError`) now exit `3` instead of `2`, aligning with `ExitCodeForCategory` (config errors share the auth exit slot per the taxonomy). The classifier and promote helpers are *shipped but unused* in production paths — they exist so framework migration can plug in without re-architecting. 2. **Builder API — ✅ shipped (this branch).** `errs/types.go` adds the canonical producer surface (`errs.NewXxxError(subtype, format, args...).WithX(...)`) for all 10 typed types, alongside each struct declaration. Constructor signature pins `Category` (via function name) and `Subtype` + `Message` (positional), so the producer cannot mis-specify any of the three identity fields. Optional fields chain through `.WithX(...)` setters that preserve the concrete pointer type. ### Next: framework migration PR (planned) A single PR consolidates the work the original §9 spec split across PRs 2–4 — restricted to framework code, no business sweep: - **Migrate `internal/**` typed construction to the builder API.** ~16 call sites in `internal/errclass/classify.go` (BuildAPIError fanout), `internal/auth/transport.go` (SecurityPolicy), `internal/auth/uat_client.go`, `internal/errcompat/promote*.go`, `internal/client/client.go`, `internal/client/api_errors.go`. - **Land the framework-side semantic changes** previously scoped to spec §9 PR 2: `SecurityPolicyError` exit `1→6`, `WrapDoAPIError` typed (`*NetworkError` with subtype timeout/tls/dns/server_error/transport, `*InternalError` for JSON-decode), `WrapJSONResponseParseError` typed, `errcompat.PromoteConfigError` real Type routing, `PromoteAuthError` helper + dispatcher wiring, 10 credential Lark codes registered in codeMeta, 99991543 config classification, `resolveAccessToken` typed `*AuthenticationError`, `BuildAPIError` filling `*PermissionError.MissingScopes` / `Identity` / `ConsoleURL`, deletion of `scopeAwareChecker`. - **Add `forbidigo` rule** banning `output.Err*` constructors in `shortcuts/**` and `cmd/**` (mirrors the contract that new business code must use the builder). - **CHANGELOG** lists the resulting ~10 shell-exit-code shifts in one release entry (vs the spec §1 spread of 11 — the remaining one site lives in `task` business code). ### Business-domain migration (self-service, no central timeline) Each business package migrates its own `output.Err*` call sites to the builder when convenient — typically batched within one domain. The guide at [`docs/errors-guide.md`](../docs/errors-guide.md) walks owners through the 8 typical error modes (validation / authorization / authentication / config / network / api / internal / policy) with real `file:line` examples from main. The three-layer extension model (add Subtype / add field / add Category) handles cases the existing taxonomy does not cover. Helper assertions accept both shapes during migration (see `shortcuts/mail/mail_shortcut_validation_test.go` `assertValidationError`) so domain migrations stay green incrementally. ### Legacy removal Deferred until business migration completion approaches the asymptote. `Errorf`, `ErrAPI`, `ErrAuth`, `ErrWithHint`, `ErrBare`, `ClassifyLarkError`, `ErrDetail`, `ExitError`, and `ErrorEnvelope` are `// Deprecated:` today and stay callable. No fixed removal date. ### Before / after at a call site ```go // before (legacy) return output.ErrAPI(larkCode, "create event failed", resp.RawBody()) // after (typed) — cc carries Brand / AppID / Identity from the caller's context return errclass.BuildAPIError(parsedResp, cc) ``` ```go // before (legacy validation) return output.ErrValidation("--duration-minutes must be between 1 and 1440") // after (builder) return errs.NewValidationError(errs.SubtypeInvalidArgument, "--duration-minutes must be between 1 and 1440, got %d", duration). WithParam("--duration-minutes") ``` ## 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 `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** - *Migrate a legacy `ExitError` call site?* → **Migration** + the Deprecated note on the symbol being replaced. - *Read source.* → `errs/doc.go` → `errs/category.go` → `errs/types.go` → `errs/predicates.go` → `internal/errclass/` → `cmd/root.go` `handleRootError`.