Files
suaveplan/openspec/archive/2026-04-19-enterprise-error/design.md
2026-05-27 23:29:53 +01:00

7.3 KiB

Design: enterprise-error

Module layout

packages/universal/error/
├── package.json                  # deps: tslib, zod. devDeps: vitest, fast-check, testing
├── tsconfig.json / tsconfig.lib.json
├── vite.config.ts                # library build (esm + dts)
├── vitest.config.ts              # thresholds 100/100/100/100, istanbul provider
├── README.md
├── src/
│   ├── index.ts                          # barrel — public API
│   ├── constants/
│   │   └── index.ts                      # MAX_CAUSE_DEPTH, REDACTED_LABEL
│   ├── base/
│   │   ├── suaveplan-error.ts            # SuaveplanError<TCode> abstract class
│   │   ├── suaveplan-error.md
│   │   ├── suaveplan-error.test.ts
│   │   └── suaveplan-error.types.ts      # options, wire shape types
│   ├── codes/
│   │   ├── define-codes.ts               # defineCodes<NS, R[]>() factory
│   │   ├── define-codes.md
│   │   └── define-codes.test.ts
│   ├── causes/
│   │   ├── walk-causes.ts                # generator, cycle-safe, depth-bounded
│   │   ├── walk-causes.md
│   │   └── walk-causes.test.ts
│   ├── serialize/
│   │   ├── to-json.ts
│   │   ├── to-json.md
│   │   ├── from-json.ts
│   │   ├── from-json.md
│   │   ├── to-binary.ts                  # MessagePack-compatible encoder
│   │   ├── to-binary.md
│   │   ├── from-binary.ts
│   │   ├── from-binary.md
│   │   ├── wire.schemas.ts               # Zod schemas (opt-in import)
│   │   └── serialize.test.ts             # cross-cutting round-trip tests
│   ├── redact/
│   │   ├── sensitive.ts                  # markSensitive / unwrapSensitive
│   │   ├── sensitive.md
│   │   └── sensitive.test.ts
│   ├── otel/
│   │   ├── to-span-event.ts
│   │   ├── to-span-event.md
│   │   └── to-span-event.test.ts
│   ├── i18n/
│   │   ├── resolver.ts                   # setMessageResolver, resolveMessage
│   │   ├── resolver.md
│   │   └── resolver.test.ts
│   └── __benchmarks__/
│       └── serialize.bench.ts
├── tests/
│   ├── setup/polyfills.ts
│   ├── dep-graph.test.ts
│   ├── no-disallowed.test.ts             # grep guard for node:/Buffer/window/etc.
│   └── tree-shake.test.ts
└── CHANGELOG.md

Key design decisions

Generic code parameter on SuaveplanError

export abstract class SuaveplanError<TCode extends string = string> extends Error {
    public readonly code: TCode;
    public readonly context?: Readonly<Record<string, unknown>>;
    public readonly timestamp: number;
    public readonly traceId?: string;
    // ...
}

Subclasses narrow TCode so switch (err.code) gets exhaustiveness checking without manual casts. Default string keeps the base usable when a consumer doesn't care about discriminants.

defineCodes returns a frozen literal-typed record

export function defineCodes<NS extends string, R extends readonly string[]>(
    ns: NS,
    reasons: R
): { readonly [K in R[number]]: `${NS}.${K}` } {
    const out = Object.create(null);
    for (const r of reasons) out[r] = `${ns}.${r}`;
    return Object.freeze(out);
}

Return type is inferred structurally — no manual enum declarations, no drift between the type and the runtime value.

Cause-chain traversal

walkCauses is a generator. The cycle guard uses a WeakSet<object> seeded with the root; non-object causes (strings, numbers) are allowed but not tracked. maxDepth is inclusive of the root and defaults to 32.

Wire format

JSON shape:

{
    __suaveplanError: 1,           // format version
    code: string,
    message: string,
    context?: Record<string, unknown>,  // redactions applied
    timestamp: number,
    traceId?: string,
    stack?: string,
    cause?: WireError,             // recursive
}

Binary format: a deterministic MessagePack-compatible encoding implemented in to-binary.ts. Only the subset needed for the wire shape is implemented (nil, bool, int up to 53 bits, float64, str, bin, array, map). No external msgpack dependency.

Redaction

markSensitive(value) returns { __sensitive: true, value }. At serialization time, toJSON/toBinary replace tagged values with the string "[REDACTED]". unwrapSensitive returns the original value for in-memory access.

Deterministic clock

Every constructor accepts { clock?: () => number }. The default module-level reference is a thin wrapper — const defaultClock = () => Date.now() — that lives in one place and can be swapped with property mocking in tests. No other file reads Date.now() directly.

i18n hook

Module-level state: let resolver: MessageResolver | undefined. setMessageResolver(fn) installs one; resolveMessage(key, params?) calls it or returns the key verbatim. No @suaveplan/i18n dependency — that package will register a resolver at app bootstrap.

Coverage strategy

Thresholds locked at 100 for every metric. Testing approach:

  1. Enumeration tests for every branch (normal + error paths)
  2. Property-based tests via fast-check for round-trip invariants (JSON, binary, cause-chain)
  3. No istanbul ignore pragmas — dead branches are refactored away (use as assertions with invariant comments when type-narrowing is the only motivation for a defensive check)
  4. Cross-cutting guards in tests/ for dep-graph, tree-shake, and no-disallowed-module-references

Public exports (initial surface)

// base
export { SuaveplanError } from "./base/suaveplan-error.js";
export type { SuaveplanErrorOptions, ErrorContext } from "./base/suaveplan-error.types.js";

// codes
export { defineCodes } from "./codes/define-codes.js";

// causes
export { walkCauses } from "./causes/walk-causes.js";

// serialize
export { toJSON } from "./serialize/to-json.js";
export { fromJSON } from "./serialize/from-json.js";
export { toBinary } from "./serialize/to-binary.js";
export { fromBinary } from "./serialize/from-binary.js";
export type { WireError } from "./serialize/wire.schemas.js";

// redact
export { markSensitive, unwrapSensitive, isSensitive } from "./redact/sensitive.js";

// otel
export { toSpanEvent } from "./otel/to-span-event.js";
export type { SpanEvent } from "./otel/to-span-event.js";

// i18n
export { setMessageResolver, resolveMessage, clearMessageResolver } from "./i18n/resolver.js";

// package-local error subclass for this package's own failure modes
export { ErrorPackageError } from "./base/package-error.js";
export { ERROR_CODES } from "./codes/error-codes.js";

ErrorPackageError is the one concrete subclass the package ships for its own internal failures (e.g. ERROR.WIRE.MALFORMED, ERROR.CAUSE.CYCLE_DETECTED). Every other package will extend SuaveplanError directly with its own codes.

Non-goals

  • Not implementing a full MessagePack codec — only the subset needed for wire payloads
  • Not shipping an i18n catalog — only the resolver hook
  • Not shipping an OTel SDK — only the span-event adapter shape
  • Not a logger — toJSON produces a shape that @suaveplan/logger can consume later