# Design: enterprise-error ## Module layout ```text 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 abstract class │ │ ├── suaveplan-error.md │ │ ├── suaveplan-error.test.ts │ │ └── suaveplan-error.types.ts # options, wire shape types │ ├── codes/ │ │ ├── define-codes.ts # defineCodes() 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` ```ts export abstract class SuaveplanError extends Error { public readonly code: TCode; public readonly context?: Readonly>; 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 ```ts export function defineCodes( 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` 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: ```ts { __suaveplanError: 1, // format version code: string, message: string, context?: Record, // 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) ```ts // 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