Files
2026-05-27 23:29:53 +01:00

8.9 KiB

MODIFIED 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: 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 reconstructed value 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 an error 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"

ADDED Requirements

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 result.cause.cause.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)