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

598 lines
29 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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:
```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: <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)
```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_<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`](../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`](../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_<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.go``errs/category.go``errs/types.go`
`errs/predicates.go``internal/errclass/`
`cmd/root.go` `handleRootError`.