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

7.6 KiB

ADDED Requirements

Requirement: Zero workspace and runtime dependencies

The @suaveplan/error package dependencies, peerDependencies, and devDependencies MUST NOT list any @suaveplan/* scoped package. Runtime dependencies MUST be limited to tslib and zod; all other external packages live in devDependencies.

Scenario: Dep-graph guard

  • WHEN bunx turbo ls --filter=@suaveplan/error... is run
  • THEN no @suaveplan/* package appears in the forward closure of @suaveplan/error

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.

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<TNamespace, TReasons>() helper that returns a frozen Record<TReasons, \${TNamespace}.${TReasons}`>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.

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.

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

Requirement: Lossless wire serialization

The package SHALL export toJSON(err) and fromJSON(value) producing a Zod-validated wire payload. Round-tripping a SuaveplanError subclass through JSON MUST preserve code, message, context (redactions applied), timestamp, traceId, 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.

Scenario: JSON round-trip preserves all fields

  • WHEN an error with { code, message, context, cause } is serialized via toJSON and parsed back via fromJSON
  • THEN the reconstructed error is instanceof SuaveplanError AND every field is structurally equal to the original (redacted fields excepted)

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]". In-memory access to the original value via err.context is UNAFFECTED.

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

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.<package>.<domain>.<reason>) which the default implementation renders by returning the key itself. Consumers can plug in a translator via setMessageResolver(fn).

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.

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