Files
larksuite-cli/errs/ERROR_CONTRACT.md
evandance 99e314fe0b feat(errs): typed envelope contract for auth-domain errors (#1135)
Every failure on the authentication, authorization, and configuration
path now surfaces as a typed structured error instead of an ad-hoc
envelope. Users and scripts that consume CLI output get:

  - a fixed nine-category taxonomy on the wire, each mapped to a
    stable shell exit code (authentication/authorization/config = 3,
    network = 4, internal = 5, policy = 6, confirmation = 10)
  - identity-aware detail fields (missing_scopes, requested_scopes,
    granted_scopes, console_url, log_id, retryable, hint) carried
    uniformly on the envelope
  - a single canonical policy envelope at exit 6; the legacy
    auth_error carve-out is retired
  - per-subtype canonical message + hint that preserves Lark's
    diagnostic phrasing and routes recovery to the right actor:
    app developer (app_scope_not_applied), user (missing_scope,
    token_scope_insufficient, user_unauthorized), or tenant admin
    (app_unavailable, app_disabled)
  - wrong app credentials classify as config/invalid_client whether
    surfaced by the Open API endpoint (99991543) or the tenant
    access-token mint endpoint (10003 / 10014), instead of
    collapsing to a transport error or api/unknown
  - local shortcut scope preflight emits the same
    authorization/missing_scope envelope (identity + deterministic
    missing-scope set) used by the post-call permission path, so AI
    consumers read the same structured shape from precheck and from
    server-returned permission denial
  - streaming download/upload failures keep the same network subtype
    split (timeout / TLS / DNS / transport) as the non-stream path
    instead of collapsing every cause to a generic transport failure
  - console_url is carried only on the bot-perspective
    app_scope_not_applied envelope (where the recovery action is
    "developer applies the scope at the developer console"); the
    user-perspective missing_scope envelope drops the field, since
    the only actionable user recovery is `lark-cli auth login --scope`
    and pointing an end user at a console they cannot modify is
    misleading
  - bind workflows (Hermes / OpenClaw / lark-channel) flatten dynamic
    Type tags to wire 'config' with the original module name kept
    as a metric label

All 10 typed errors are cause-bearing, nil-safe on .Error() and
.Unwrap(), and defensively clone slice setter inputs. Four lint
rules (CheckNilSafeError / CheckBuilderImmutable / CheckUnwrapSymmetry
/ CheckBuildAPIErrorArms) lock these invariants on migrated paths.
2026-05-30 19:08:41 +08:00

29 KiB
Raw Permalink Blame History

lark-cli Error Contract

errs/ defines a typed, RFC 7807aligned 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:

{
  "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: <msg>" (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)

var pe *errs.PermissionError
if errors.As(err, &pe) {
    fmt.Println("missing:", pe.MissingScopes)
}

Predicates cover the common categories (errs/predicates.go):

if errs.IsAuthentication(err)       { ... }
if errs.IsPermission(err) { ... }
if errs.IsValidation(err) { ... }

Type-agnostic field access:

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

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:

_, err := runtime.DoAPI(req, opts)
if err != nil {
    return err // already typed by the framework boundary
}

Non-conforming:

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:

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:

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:

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:

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):

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).

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_<service>.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.<X>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 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 24 — 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 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

// 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)
// 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_<service>.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.goerrs/category.goerrs/types.goerrs/predicates.gointernal/errclass/cmd/root.go handleRootError.