# error ## Purpose Foundation error substrate for the Suaveplan monorepo — `SuaveplanError` base class, stable code registry via `defineCodes`, cause-chain traversal, Zod-validated wire format (JSON + binary), redaction at the serialization boundary, OTel span-event adapter, and i18n resolver hook. Has zero `@suaveplan/*` runtime dependencies (carve-out for the types-only `@suaveplan/types`). ## Requirements ### Requirement: Zero workspace and runtime dependencies The `@suaveplan/error` package's `dependencies` MUST be limited to `tslib`, `zod`, and `@suaveplan/types`. No `peerDependencies`. No other `@suaveplan/*` packages at runtime. The `@suaveplan/types` carve-out is permitted exclusively because it ships zero runtime code (types-only build, byte-size guarded by its own conformance test) and therefore cannot reintroduce the circular foundation-of-foundations hazard the rule otherwise prevents. **Verified by:** `tests/no-disallowed.test.ts` and the `package.json` dep-set under `bunx turbo ls --filter=@suaveplan/error...`. #### Scenario: Dep-graph guard - **WHEN** `bunx turbo ls --filter=@suaveplan/error...` is run - **THEN** the only `@suaveplan/*` packages in the forward closure are `@suaveplan/error` itself and `@suaveplan/types` #### Scenario: No Node / browser built-ins - **WHEN** the built `dist/` is grep'd - **THEN** it MUST NOT reference `node:fs`, `node:path`, `node:url`, `process`, `Buffer`, `document`, `window`, or any DOM / Node-only API ### Requirement: SuaveplanError base class The package SHALL export an abstract `SuaveplanError` class extending the built-in `Error`, carrying `{ code, message, cause?, context?, timestamp, traceId? }`. `code` SHALL be a string of the form `PACKAGE.DOMAIN.REASON` (plain uppercase ASCII, dots as separators). The `code` field is generic so subclasses can narrow the code union. **Verified by:** `src/base/suaveplan-error.test.ts` and `src/base/package-error.test.ts`. #### Scenario: Subclass narrows code union - **WHEN** a consumer defines `class MyError extends SuaveplanError<"MY.X.Y" | "MY.X.Z">` - **THEN** TypeScript infers `err.code` as `"MY.X.Y" | "MY.X.Z"` (not `string`) inside `instanceof MyError` branches #### Scenario: Stack trace preserved - **WHEN** a `SuaveplanError` subclass is thrown - **THEN** `err.stack` contains the subclass name (not `Error`) AND the originating call site #### Scenario: Timestamp is injectable - **WHEN** a subclass is constructed with `{ clock: () => 1234 }` - **THEN** `err.timestamp === 1234` #### Scenario: Default timestamp uses Date.now - **WHEN** a subclass is constructed with no `clock` option - **THEN** `err.timestamp` equals the `Date.now()` reading at construction time ### Requirement: Stable error-code registry helper The package SHALL export a `defineCodes()` helper that returns a frozen `Record` with full literal-type inference. Rebuilding the registry MUST preserve existing codes; removal requires a `@deprecated` marker that emits a TypeScript deprecation warning at call sites. **Verified by:** `src/codes/define-codes.test.ts` and `src/codes/error-codes.ts`. #### Scenario: Registry is frozen - **WHEN** a consumer attempts `codes.REASON = "X.Y.Z"` - **THEN** the assignment throws in strict mode (object is frozen) #### Scenario: Code values are literal-typed - **WHEN** a consumer writes `const codes = defineCodes("PKG", ["A", "B"] as const)` - **THEN** `codes.A` has type `"PKG.A"` (not `string`) ### Requirement: Cause-chain traversal is cycle-safe and depth-bounded The package SHALL export `walkCauses(err, { maxDepth?: number })` as a generator yielding each error in the chain. Default `maxDepth` is 32. Cycles MUST be detected via a `WeakSet` and terminate iteration without throwing. The same cycle-and-depth discipline SHALL apply to `toJSON` serialization: when traversal encounters a cycle OR exceeds `maxDepth`, the cause field is replaced with the `TruncatedCause` sentinel (see "TruncatedCause sentinel" requirement) rather than throwing or looping. **Verified by:** `src/causes/walk-causes.test.ts` and `src/conformance.spec-conformance.test.ts` (toJSON cycle + depth-cap scenarios). #### Scenario: Linear chain fully traversed - **WHEN** `a.cause = b; b.cause = c; c.cause = d` - **THEN** `Array.from(walkCauses(a))` returns `[a, b, c, d]` #### Scenario: Cycle terminates iteration - **WHEN** `a.cause = b; b.cause = a` and `walkCauses(a)` is consumed - **THEN** iteration yields `a` then `b` and ends (does not throw, does not loop forever) #### Scenario: Depth cap enforced - **WHEN** a chain of 100 errors is walked with `{ maxDepth: 10 }` - **THEN** exactly 10 errors are yielded #### Scenario: toJSON cycle yields TruncatedCause sentinel - **WHEN** `a.cause = b; b.cause = a` and `toJSON(a)` runs - **THEN** `result.cause.cause.code === "ERROR.CAUSE.TRUNCATED"` AND iteration does not loop forever ### Requirement: Lossless wire serialization The package SHALL export `toJSON(err)` and `fromJSON(value)` producing a Zod-validated wire payload. `fromJSON` returns a `ReconstructedError` POJO carrying the `__suaveplanError: 1` discriminator — NOT a real `SuaveplanError` instance, since the base class is `abstract` and consumer-defined subclasses are not on the wire. Round-tripping a `SuaveplanError` subclass through JSON MUST preserve `code`, `message`, `context` (redactions applied), `timestamp`, `traceId`, `name`, and the cause chain (up to `maxDepth`). The binary form (`toBinary` / `fromBinary`) is a deterministic UTF-8 encoding of the canonicalised JSON form (no external encoder dep); both halves of a round-trip produce structurally identical output. **Verified by:** `src/serialize/serialize.test.ts` and `src/conformance.spec-conformance.test.ts` (discriminator round-trip scenario). #### Scenario: JSON round-trip preserves structural fields and discriminator - **WHEN** an error with `{ code, message, context, cause }` is serialized via `toJSON` and parsed back via `fromJSON` - **THEN** the reconstructed value carries `__suaveplanError === 1` AND every wire field (`code`, `message`, `context` post-redaction, `timestamp`, `traceId`, `name`, `cause`) is structurally equal to the original AND the result is NOT `instanceof SuaveplanError` (intentionally — base class is abstract) #### Scenario: Binary round-trip matches JSON round-trip - **WHEN** the same error is passed through `toBinary`/`fromBinary` - **THEN** the resulting error is structurally equal to the JSON round-trip output #### Scenario: Zod rejects malformed wire payload - **WHEN** `fromJSON({ code: 123 })` is called (wrong type) - **THEN** it throws a `SuaveplanError` subclass with `code === "ERROR.WIRE.MALFORMED"` AND `cause instanceof ZodError` ### Requirement: Redaction at the serialization boundary The package SHALL export `markSensitive(value)` producing a tagged wrapper. During `toJSON` / `toBinary`, tagged values MUST be replaced with the literal string `"[REDACTED]"` AT ANY DEPTH within `err.context` — including values nested inside arrays and sub-objects. The redaction walk MUST be cycle-safe (a circular context graph terminates without throwing or looping). In-memory access to the original value via `err.context` is UNAFFECTED. **Verified by:** `src/redact/sensitive.test.ts`, `src/serialize/serialize.test.ts`, and `src/conformance.spec-conformance.test.ts` (deep + cycle-safe redaction scenarios). #### Scenario: Sensitive field redacted on the wire - **WHEN** `err.context.token = markSensitive("abc")` and `toJSON(err)` runs - **THEN** the serialized payload contains `context.token === "[REDACTED]"` (string) #### Scenario: Nested sensitive value redacted - **WHEN** `err.context.user = { profile: { token: markSensitive("abc") } }` and `toJSON(err)` runs - **THEN** `result.context.user.profile.token === "[REDACTED]"` #### Scenario: Sensitive value inside array redacted - **WHEN** `err.context.tokens = [markSensitive("a"), markSensitive("b")]` and `toJSON(err)` runs - **THEN** `result.context.tokens === ["[REDACTED]", "[REDACTED]"]` #### Scenario: Cyclic context terminates redaction - **WHEN** `err.context.self` is set to a cycle (`ctx.self = ctx`) and `toJSON(err)` runs - **THEN** redaction completes without throwing or looping (cycle short-circuits via `WeakSet`) #### Scenario: In-memory value remains accessible - **WHEN** `err.context.token = markSensitive("abc")` (unserialized) - **THEN** `unwrapSensitive(err.context.token) === "abc"` ### Requirement: OTel span event adapter The package SHALL export `toSpanEvent(err)` returning `{ name: "exception", attributes: { "exception.type", "exception.message", "exception.stacktrace", "exception.code", "exception.traceId"? } }` with all values already string-serialized. No OpenTelemetry SDK is imported. **Verified by:** `src/otel/to-span-event.test.ts`. #### Scenario: Span event matches OTel spec - **WHEN** `toSpanEvent(err)` is called - **THEN** the returned `name === "exception"` AND `attributes["exception.type"] === err.constructor.name` AND `attributes["exception.message"] === err.message` AND `attributes["exception.stacktrace"] === err.stack ?? ""` AND `attributes["exception.code"] === err.code` ### Requirement: i18n-ready messages No user-visible string literal SHALL be embedded in a `throw` site. Subclasses MUST instead reference an i18n key (of the form `error...`) which the default implementation renders by returning the key itself. Consumers can plug in a translator via `setMessageResolver(fn)`. **Verified by:** `src/i18n/resolver.test.ts` and `src/base/suaveplan-error.test.ts` (resolver-applied scenarios). #### Scenario: Default resolver returns the key - **WHEN** no resolver has been set and a subclass formats `"error.pkg.domain.reason"` - **THEN** the rendered message equals `"error.pkg.domain.reason"` verbatim #### Scenario: Custom resolver applied - **WHEN** `setMessageResolver((key, params) => translations[key](params))` has been set - **THEN** subclass construction calls the resolver and stores the returned string on `err.message` ### Requirement: Test coverage at 100% The package MUST maintain `statements: 100`, `branches: 100`, `functions: 100`, `lines: 100` thresholds in `vitest.config.ts`. No `istanbul ignore` pragmas are permitted (they do not survive the esbuild transform). Defensive code whose false branch is unreachable MUST be rewritten as an `as` assertion with an invariant comment rather than a runtime `if`. **Verified by:** `vitest.config.ts` thresholds and the full package test suite under `bunx turbo test:coverage --filter=@suaveplan/error`. #### Scenario: Coverage thresholds enforced - **WHEN** `bunx turbo test:coverage --filter=@suaveplan/error` runs - **THEN** it exits non-zero if any of statements / branches / functions / lines drops below 100 #### Scenario: Property-based round-trip - **WHEN** fast-check generates arbitrary `SuaveplanError` subclass instances - **THEN** every instance survives `toJSON` → `fromJSON` structurally equal (modulo redactions) AND every instance survives `toBinary` → `fromBinary` structurally equal ### Requirement: TruncatedCause sentinel When `toJSON` encounters either (a) a cause-chain depth that exceeds `maxDepth` OR (b) an already-seen object (cycle), the cause field SHALL be replaced with a sentinel `WireError` of shape `{ __suaveplanError: 1, code: "ERROR.CAUSE.TRUNCATED", name: "TruncatedCause", message: "[cause chain truncated]", timestamp: 0 }`. The sentinel is structurally a valid `WireError` (passes Zod validation) and is therefore safely consumable by `fromJSON` downstream. The sentinel MUST NOT introduce a real `Error` throw; truncation is a non-fatal terminal event in the chain. **Verified by:** `src/conformance.spec-conformance.test.ts` (depth + cycle sentinel scenarios) and `src/serialize/serialize.test.ts`. #### Scenario: Depth-cap emits sentinel - **WHEN** `toJSON(err, { maxDepth: 1 })` runs on an error with `cause.cause.cause` of depth 3 - **THEN** the chain at the cap position contains the `TruncatedCause` sentinel AND the sentinel's `code === "ERROR.CAUSE.TRUNCATED"` #### Scenario: Cycle emits sentinel - **WHEN** `toJSON(a)` runs on `a.cause = b; b.cause = a` (cycle) - **THEN** the chain terminates at the cycle with the sentinel AND no infinite loop occurs #### Scenario: Sentinel survives round-trip - **WHEN** a `toJSON` output containing a `TruncatedCause` sentinel is passed back through `fromJSON` - **THEN** the reconstructed value carries the sentinel intact (`code === "ERROR.CAUSE.TRUNCATED"`, `name === "TruncatedCause"`) without throwing `WIRE.MALFORMED` ### Requirement: Wire format version discriminator The wire payload SHALL carry a `__suaveplanError: 1` discriminator literal validated by Zod's `z.literal(1)`. This identifies the payload as a `@suaveplan/error` wire envelope and pins the format version. `WIRE_VERSION` is the single source-of-truth constant in `src/constants/index.ts`. `fromJSON` MUST reject payloads where `__suaveplanError` is missing OR not equal to `WIRE_VERSION` with `code === "ERROR.WIRE.MALFORMED"` (Zod-driven). When `WIRE_VERSION` is bumped (future), payload migration is the consumer's responsibility — there is no implicit version negotiation in this package. **Verified by:** `src/serialize/wire.schemas.ts` (Zod literal), `src/serialize/serialize.test.ts`, and `src/conformance.spec-conformance.test.ts` (discriminator scenarios). #### Scenario: Discriminator preserved through round-trip - **WHEN** an error is passed through `toJSON` then `fromJSON` - **THEN** the reconstructed value has `__suaveplanError === 1` #### Scenario: Missing discriminator rejected - **WHEN** `fromJSON({ code: "X.Y.Z", message: "m", timestamp: 0, name: "E" })` runs (no `__suaveplanError`) - **THEN** it throws with `code === "ERROR.WIRE.MALFORMED"` #### Scenario: Wrong discriminator rejected - **WHEN** `fromJSON({ __suaveplanError: 2, ...rest })` runs - **THEN** it throws with `code === "ERROR.WIRE.MALFORMED"` (Zod literal mismatch)