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

14 KiB

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

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.<package>.<domain>.<reason>) 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)